Community project
Control Automático de Pistón Neumático
This project automates a pneumatic piston using an ESP32 microcontroller to control a dual-solenoid 5/2 valve through a 4-channel relay module. The system cycles the cylinder between extension and retraction phases with configurable timing, tracks completed cycles, and provides pause control via an external push button.
The guide includes a complete wiring diagram connecting the relay module to the ESP32 DevKit v1, a parts list, and Arduino firmware with web-based control. Assembly covers safe handling of pneumatic connections, relay wiring to solenoid coils, and no-load startup testing. The firmware implements state management, debounced button input, and a web interface for adjusting cycle parameters and monitoring operation.
Wiring diagram

Gather all the parts
Assemble it in 5 steps
1. Deja segura la parte neumática y de potencia
Corta la alimentación de la electroválvula y descarga la presión del circuito antes de tocar cables. Usa los contactos COM y NO de relay_module como un interruptor en serie con la alimentación de cada bobina: un relé para la bobina de avance y el otro para la bobina de retroceso. La fuente de la válvula debe tener el voltaje indicado en su etiqueta; no la alimentes desde la placa.
- Identifica claramente las dos bobinas de la válvula 5/2 antes de cablear: avance y retroceso.
- Una válvula de 12, 24 o 110 V conectada por error a los pines de la placa puede destruirla y crear un riesgo de descarga o movimiento inesperado.
- Instala un paro de emergencia cableado que corte la alimentación de las bobinas; el botón Pause no sustituye un dispositivo de seguridad.
2. Conecta el módulo de relés al ESP32 DevKit v1
Con el ESP32 DevKit v1 desconectado del USB, conecta VCC de relay_module al pin VIN/5V (alimentación). Conecta GND de relay_module a GND (tierra común). Conecta IN1 a GPIO25 (orden de avance) e IN2 a GPIO26 (orden de retroceso). Deja IN3 e IN4 sin conectar.
- Los módulos de relé de este tipo suelen activarse cuando la entrada está a nivel bajo; el programa ya parte con los dos relés apagados.
- No conectes la bobina de la electroválvula a GPIO25 ni GPIO26: esos pines solo mandan la entrada del módulo.
- Si el módulo no responde de forma confiable a 3.3 V en IN1/IN2, usa una interfaz de relés especificada para lógica de 3.3 V; no apliques 5 V de vuelta a un GPIO.
3. Conecta el botón Pause externo
En pause_button, conecta un terminal a GPIO33 (señal) y el otro a GND (tierra). Si el pulsador tiene cuatro patas, usa dos patas que no estén ya unidas por el mismo lado del botón. El inicio no necesita botón: la secuencia comienza sola al encender la placa.
- El programa usa la resistencia interna del ESP32: al pulsar Pause, GPIO33 se une a GND y una pulsación cuenta una sola vez.
- No conectes el botón Pause al pin VIN/5 V; GPIO33 está hecho para leer una señal de 3.3 V.
4. Cablea las bobinas de la válvula a los contactos de relé
Usa una fuente independiente adecuada para la válvula. Lleva el positivo o fase de esa fuente a COM del canal IN1 y desde NO de ese canal a la bobina de avance. Repite con COM y NO del canal IN2 hacia la bobina de retroceso. Conecta el otro cable de cada bobina al retorno de su propia fuente. Para bobinas de corriente continua, coloca un diodo de rueda libre en paralelo con cada bobina, con la raya del diodo hacia el positivo.
- Usar NO hace que las dos bobinas queden sin alimentación cuando la placa está apagada o el programa aún no ha iniciado.
- Para una válvula alimentada con corriente alterna o tensión de red, no uses el diodo de rueda libre y no trabajes energizado; utiliza protecciones y montaje eléctrico adecuados.
- Nunca energices las dos bobinas de una válvula de doble solenoide a la vez; el programa lo evita y el cableado debe mantener los canales separados.
5. Prueba el arranque automático sin carga
Con el cilindro despejado y sin personas en su recorrido, conecta el ESP32 DevKit v1 por USB. La secuencia empieza automáticamente: el pistón avanza y retrocede repetidamente. Pulsa el botón Pause externo para apagar de inmediato las dos salidas. Cada ciclo completo tarda 2 segundos y el equipo se detiene al llegar a 15,000 ciclos.
- El conteo aumenta solo después de que termina el retroceso.
- El pistón se moverá al alimentar la placa. No la conectes hasta que el recorrido esté despejado y el paro de emergencia físico esté listo.
- Un control basado solo en tiempo no detecta un atasco, falta de aire ni un cilindro que no llegó al final; para operación productiva añade finales de carrera y enclavamientos de seguridad físicos.
Review all connections
1. Connections between "relay_module" and "ESP32"
2. Connections between "pause_button" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
// Cilindro 5/2 de doble solenoide. Para uso real instale finales de carrera
// y un paro de emergencia cableado que corte la alimentación de las válvulas.
enum MotionState { PAUSED, EXTENDING, RETRACTING, COMPLETE, HOLDING_OPEN, HOLDING_CLOSED };
// Forward declarations
void writeRelay(int pin, bool energized);
void allSolenoidsOff();
bool pressedEvent();
void beginExtend();
void beginRetract();
void pauseMachine();
void startMachine();
void holdOpen();
void holdClosed();
void sendStatus();
void handleRoot();
void handleSettings();
void handleControl();
void handleHold();
void setupWifi();
const int RELAY_EXTEND_PIN = 25;
const int RELAY_RETRACT_PIN = 26;
const int PAUSE_BUTTON_PIN = 33;
const bool RELAY_ACTIVE_LOW = true;
const uint32_t TARGET_CYCLES = 15000;
const unsigned long MIN_PHASE_TIME_MS = 500;
const unsigned long MAX_PHASE_TIME_MS = 3000;
const unsigned long DEBOUNCE_MS = 40;
const char *WIFI_SSID = "Piston-ESP32";
const char *WIFI_PASSWORD = "Piston2026";
WebServer server(80);
MotionState state = PAUSED;
uint32_t completedCycles = 0;
unsigned long extendTimeMs = 3000;
unsigned long retractTimeMs = 3000;
unsigned long phaseStartedAt = 0;
bool lastPauseReading = HIGH;
bool stablePauseState = HIGH;
unsigned long pauseChangedAt = 0;
void writeRelay(int pin, bool energized) {
digitalWrite(pin, (RELAY_ACTIVE_LOW ? !energized : energized) ? HIGH : LOW);
}
void allSolenoidsOff() {
writeRelay(RELAY_EXTEND_PIN, false);
writeRelay(RELAY_RETRACT_PIN, false);
}
const char *stateName() {
switch (state) {
case EXTENDING: return "Avanzando: válvula de apertura activada";
case RETRACTING: return "Cerrando: válvula de cierre activada";
case COMPLETE: return "Meta de 15 000 ciclos alcanzada";
case HOLDING_OPEN: return "Posición fija ABIERTA";
case HOLDING_CLOSED: return "Posición fija CERRADA";
default: return "En pausa: esperando Inicio desde el teléfono";
}
}
bool pressedEvent() {
bool reading = digitalRead(PAUSE_BUTTON_PIN);
unsigned long now = millis();
if (reading != lastPauseReading) {
lastPauseReading = reading;
pauseChangedAt = now;
}
if (now - pauseChangedAt >= DEBOUNCE_MS && reading != stablePauseState) {
stablePauseState = reading;
return stablePauseState == LOW;
}
return false;
}
void beginExtend() {
allSolenoidsOff();
writeRelay(RELAY_EXTEND_PIN, true);
phaseStartedAt = millis();
state = EXTENDING;
Serial.printf("Ciclo %lu: avance\n", (unsigned long)(completedCycles + 1));
}
void beginRetract() {
allSolenoidsOff();
writeRelay(RELAY_RETRACT_PIN, true);
phaseStartedAt = millis();
state = RETRACTING;
Serial.printf("Ciclo %lu: retroceso\n", (unsigned long)(completedCycles + 1));
}
void pauseMachine() {
allSolenoidsOff();
state = PAUSED;
Serial.printf("Pausado en %lu ciclos completos\n", (unsigned long)completedCycles);
}
void startMachine() {
if (completedCycles >= TARGET_CYCLES) {
state = COMPLETE;
allSolenoidsOff();
return;
}
beginExtend();
}
void holdOpen() {
allSolenoidsOff();
writeRelay(RELAY_EXTEND_PIN, true);
state = HOLDING_OPEN;
Serial.println("Posición abierta fija activada.");
}
void holdClosed() {
allSolenoidsOff();
writeRelay(RELAY_RETRACT_PIN, true);
state = HOLDING_CLOSED;
Serial.println("Posición cerrada fija activada.");
}
void sendStatus() {
char json[290];
snprintf(json, sizeof(json),
"{\"cycles\":%lu,\"target\":%lu,\"state\":\"%s\",\"extend\":%lu,\"retract\":%lu,\"hold\":\"%s\"}",
(unsigned long)completedCycles, (unsigned long)TARGET_CYCLES, stateName(),
extendTimeMs, retractTimeMs,
state == HOLDING_OPEN ? "open" : (state == HOLDING_CLOSED ? "closed" : "none"));
server.send(200, "application/json", json);
}
void handleRoot() {
// Static PROGMEM keeps this large page out of the ESP32 task stack.
static const char page[] PROGMEM = R"HTML(<!doctype html><html lang="es"><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Control de pistón</title><style>
body{font-family:Arial,sans-serif;background:#10232d;color:#fff;text-align:center;margin:0;padding:1rem}.card{max-width:460px;margin:auto;background:#1d3947;border-radius:18px;padding:1.25rem;box-shadow:0 4px 16px #0006}h1{font-size:1.5rem;margin:.3rem}.number{font-size:4rem;font-weight:bold;color:#70e0aa;margin:.3rem}.status{font-size:1.08rem;background:#142c37;border-radius:10px;padding:.7rem}.small,.notice{color:#b9ced7}.control{text-align:left;margin:1.2rem 0}.control label{display:flex;justify-content:space-between;font-weight:bold}.control output{color:#70e0aa}input[type=range]{width:100%;margin-top:.65rem;accent-color:#70e0aa}.buttons{display:grid;grid-template-columns:repeat(3,1fr);gap:.55rem;margin:1rem 0}button{border:0;border-radius:10px;padding:.85rem .35rem;font-size:1rem;font-weight:bold;color:white;background:#30718a}button.pause{background:#bd6a31}button.reset{background:#8b4656}.hold{background:#142c37;border-radius:12px;padding:1rem;margin:1rem 0}.switchrow{display:flex;align-items:center;justify-content:center;gap:.65rem;font-weight:bold}.switch{position:relative;width:62px;height:32px;display:inline-block}.switch input{opacity:0;width:0;height:0}.slider{position:absolute;inset:0;background:#c26a4b;border-radius:20px}.slider:before{content:'';position:absolute;width:26px;height:26px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s}.switch input:checked+.slider{background:#338c6a}.switch input:checked+.slider:before{transform:translateX(30px)}.notice{font-size:.85rem}#message{min-height:1.4em;color:#ffd27d}
</style></head><body><main class="card"><h1>Control de pistón</h1><div class="small">Ciclos completos</div><div class="number" id="count">--</div><div class="small">de <span id="target">15000</span></div><p class="status" id="machineState">Conectando con el ESP32…</p><p id="message"></p><div class="buttons"><button id="startBtn">Inicio</button><button class="pause" id="pauseBtn">Pausa</button><button class="reset" id="resetBtn">Reiniciar<br>en 0</button></div><div class="hold"><b>Posición fija</b><p class="small">Cerrada <label class="switch"><input id="holdSwitch" type="checkbox"><span class="slider"></span></label> Abierta</p><p class="notice">El interruptor deja el pistón fijo en la posición elegida. Pulse Inicio para volver al ciclo automático.</p></div><div class="control"><label for="extend">Tiempo de apertura <output id="extendValue">--</output></label><input id="extend" type="range" min="500" max="3000" step="100"></div><div class="control"><label for="retract">Tiempo de cierre <output id="retractValue">--</output></label><input id="retract" type="range" min="500" max="3000" step="100"></div><p class="notice">Al encender, el cilindro queda pausado. Pulse Inicio aquí para comenzar. Los tiempos se aplican al siguiente movimiento.</p></main><script>
const el={count:document.getElementById('count'),target:document.getElementById('target'),machineState:document.getElementById('machineState'),message:document.getElementById('message'),extend:document.getElementById('extend'),retract:document.getElementById('retract'),extendValue:document.getElementById('extendValue'),retractValue:document.getElementById('retractValue'),hold:document.getElementById('holdSwitch'),start:document.getElementById('startBtn'),pause:document.getElementById('pauseBtn'),reset:document.getElementById('resetBtn')};let initialized=false;let sending=false;function sec(v){return(Number(v)/1000).toFixed(1).replace('.',',')+' s'}function showTimes(){el.extendValue.textContent=sec(el.extend.value);el.retractValue.textContent=sec(el.retract.value)}async function api(path){const r=await fetch(path,{cache:'no-store'});if(!r.ok)throw new Error('HTTP '+r.status);return r.json()}function draw(d){el.count.textContent=d.cycles;el.target.textContent=d.target;el.machineState.textContent='Estado: '+d.state;if(!initialized){el.extend.value=d.extend;el.retract.value=d.retract;showTimes();initialized=true}if(!sending)el.hold.checked=d.hold==='open'}async function refresh(){try{draw(await api('/status'))}catch(e){el.machineState.textContent='Estado: sin comunicación con el ESP32'}}async function command(cmd,label){if(sending)return;sending=true;el.message.textContent='Enviando '+label+'…';try{draw(await api('/control?cmd='+encodeURIComponent(cmd)));el.message.textContent=label+' enviado correctamente.'}catch(e){el.message.textContent='No se pudo enviar la orden. Revise la conexión Wi‑Fi.'}sending=false}async function setHold(){if(sending)return;sending=true;const position=el.hold.checked?'open':'closed';el.message.textContent='Fijando posición…';try{draw(await api('/hold?position='+position));el.message.textContent='Posición fija activada.'}catch(e){el.message.textContent='No se pudo fijar la posición.'}sending=false}async function saveTimes(){showTimes();try{draw(await api('/settings?extend='+el.extend.value+'&retract='+el.retract.value));el.message.textContent='Tiempos guardados.'}catch(e){el.message.textContent='No se pudieron guardar los tiempos.'}}el.start.addEventListener('click',()=>command('start','Inicio'));el.pause.addEventListener('click',()=>command('pause','Pausa'));el.reset.addEventListener('click',()=>{if(confirm('¿Detener el equipo y reiniciar el contador a 0?'))command('reset','Reinicio')});el.hold.addEventListener('change',setHold);el.extend.addEventListener('input',showTimes);el.retract.addEventListener('input',showTimes);el.extend.addEventListener('change',saveTimes);el.retract.addEventListener('change',saveTimes);refresh();setInterval(()=>{if(!sending)refresh()},1000);
</script></body></html>)HTML";
server.send_P(200, "text/html; charset=utf-8", page);
}
void handleSettings() {
if (server.hasArg("extend")) extendTimeMs = constrain(server.arg("extend").toInt(), (long)MIN_PHASE_TIME_MS, (long)MAX_PHASE_TIME_MS);
if (server.hasArg("retract")) retractTimeMs = constrain(server.arg("retract").toInt(), (long)MIN_PHASE_TIME_MS, (long)MAX_PHASE_TIME_MS);
Serial.printf("Tiempos ajustados: apertura %lu ms, cierre %lu ms\n", extendTimeMs, retractTimeMs);
sendStatus();
}
void handleControl() {
String cmd = server.arg("cmd");
if (cmd == "start") startMachine();
else if (cmd == "pause") pauseMachine();
else if (cmd == "reset") { pauseMachine(); completedCycles = 0; Serial.println("Contador reiniciado a 0."); }
else { server.send(400, "text/plain", "Orden no valida"); return; }
sendStatus();
}
void handleHold() { if (server.arg("position") == "open") holdOpen(); else holdClosed(); sendStatus(); }
void setupWifi() {
WiFi.mode(WIFI_AP);
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
server.on("/", HTTP_GET, handleRoot);
server.on("/status", HTTP_GET, sendStatus);
server.on("/settings", HTTP_GET, handleSettings);
server.on("/control", HTTP_GET, handleControl);
server.on("/hold", HTTP_GET, handleHold);
server.onNotFound(handleRoot);
server.begin();
Serial.printf("Wi-Fi listo: conecte el teléfono a %s y abra http://%s\n", WIFI_SSID, WiFi.softAPIP().toString().c_str());
}
void setup() {
Serial.begin(115200);
pinMode(RELAY_EXTEND_PIN, OUTPUT);
pinMode(RELAY_RETRACT_PIN, OUTPUT);
allSolenoidsOff();
pinMode(PAUSE_BUTTON_PIN, INPUT_PULLUP);
setupWifi();
Serial.println("Listo. El equipo queda pausado hasta pulsar Inicio desde el teléfono.");
}
void loop() {
server.handleClient();
if (pressedEvent() && state != COMPLETE) pauseMachine();
unsigned long now = millis();
if (state == EXTENDING && now - phaseStartedAt >= extendTimeMs) beginRetract();
else if (state == RETRACTING && now - phaseStartedAt >= retractTimeMs) {
allSolenoidsOff();
completedCycles++;
Serial.printf("Ciclos completos: %lu/%lu\n", (unsigned long)completedCycles, (unsigned long)TARGET_CYCLES);
if (completedCycles >= TARGET_CYCLES) { state = COMPLETE; Serial.println("Objetivo alcanzado: ambos solenoides desactivados."); }
else beginExtend();
}
}Remix this project
Make it yours in one click
Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.




