What I Built
The HSV rig was a color-mixing exercise: three channels, three knobs, three screens. This is the same rig with the color taken out and a motor bolted on the end.
The three HSV channels became three generic ones, Rail 1 through Rail 3, each still 0–100, each still driven by its own encoder and its own slider on the web page. Only what hangs off the end changed. I wired Rail 1 to an A4988 driver and a 24V stepper motor, and what I actually wanted to find out was whether the rest of the system would keep running while the motor turned. It does: GPIO 13 goes on emitting step pulses while the OLEDs refresh, the encoders count and the web page answers. That was the point of the test.
Only two pins are new. GPIO 12 carries direction, GPIO 13 carries step. SLEEP and RESET are tied to 3.3V, the three microstepping pins and ENABLE are tied to ground, and Rails 2 and 3 have nothing attached yet.
Hardware
| Component | Qty | Purpose |
|---|---|---|
| ESP32 | 1 | Main controller |
| A4988 stepper driver | 1 | Stepper motor driver |
| 24V stepper motor | 1 | Actuator |
| 24V external power supply | 1 | Motor power |
| 47µF / 50V electrolytic capacitor | 1 | VMOT power filtering |
| TCA9548A | 1 | I2C multiplexer |
| 1.3“ SH1106 OLED + EC11 module | 3 | Rail display and control |
The motor runs from its own 24V supply. The ESP32 only sends logic signals.
| ESP32 GPIO | Function |
|---|---|
| GPIO 21 | I2C SDA |
| GPIO 22 | I2C SCL |
| GPIO 25 | Encoder 1 A |
| GPIO 18 | Encoder 1 B |
| GPIO 26 | Encoder 2 A |
| GPIO 27 | Encoder 2 B |
| GPIO 4 | Encoder 3 A |
| GPIO 16 | Encoder 3 B |
| GPIO 12 | A4988 DIRECTION |
| GPIO 13 | A4988 STEP |
Apart from those two, every pin sits exactly where it was in the HSV build.
A4988 Wiring
| A4988 Pin | Connection | Function |
|---|---|---|
| DIRECTION | ESP32 GPIO 12 | Motor direction |
| STEP | ESP32 GPIO 13 | Step pulse |
| SLEEP | 3.3V | Keep driver awake |
| RESET | 3.3V | Release reset |
| MS1 | GND | Full-step mode |
| MS2 | GND | Full-step mode |
| MS3 | GND | Full-step mode |
| ENABLE | GND | Driver continuously enabled |
The A4988 has two power domains, and keeping them apart is the whole trick. VDD takes the ESP32’s 3.3V for the logic side. VMOT takes the external 24V for the motor side. The 24V negative terminal goes to the A4988’s motor-side GND, and the ESP32 GND joins that same node, so STEP and DIRECTION share a reference with the driver. The 24V supply never touches the ESP32’s 3.3V or 5V pins.
A 47µF / 50V electrolytic sits right at the VMOT input, positive to VMOT and negative to GND. It gives the motor supply local energy storage and damps the voltage swings the driver’s switching creates. 50V rating on a 24V rail leaves comfortable headroom.
The motor has four wires and two coils: 1A/1B for coil A, 2A/2B for coil B. Wire colors vary between motors, so the reliable way to pair them is a multimeter. Two wires from the same coil show a measurable resistance; wires from different coils read open.
I ran the first test at full step, with MS1, MS2 and MS3 all tied to ground, and that was deliberate. Full step keeps the relationship between STEP pulses and movement obvious, which is what a first test needs. Microstepping can wait until smoothness or fine positioning actually matters.
Rail Architecture
The HSV variables hVal, sVal and vVal became rail1Val, rail2Val and rail3Val. Each Rail spans 0–100. Encoder 1 drives Rail 1, encoder 2 drives Rail 2, encoder 3 drives Rail 3. The web sliders write the same values, and each OLED shows its own Rail. The three channels no longer know anything about color.
Rail 1 maps linearly onto a target step count:
Rail 1 = 0 → 0 steps
Rail 1 = 10 → 100 steps
Rail 1 = 50 → 500 steps
Rail 1 = 100 → 1000 steps
That 1000-step ceiling is a test value, not one motor revolution. How far the shaft actually turns depends on the motor’s step angle and the microstepping setting.
Motor Software
STEP and DIRECTION do all the work. The firmware keeps two positions:
long currentStep;
long targetStep;
targetStep is where Rail 1 asked the motor to go, currentStep is where the software thinks it is. When the two differ, the controller pulses STEP until they match, with DIRECTION set by whichever side is ahead.
None of that blocks the main loop. Each pass runs the web server, the encoder handling, one motor step every 1000µs and an OLED refresh, then starts over. The web page and the other two encoders stay responsive the whole time the motor is moving. The same structure will take three A4988s later without a rewrite.
Web Interface
The page keeps the HSV rig’s layout with three Rail sliders in place of the color controls. Moving Rail 1 in the browser changes the motor target exactly the way turning encoder 1 does. The /get endpoint exposes the current Rail values along with currentStep and targetStep, and the browser syncs against it on a timer.
Testing and Result
I didn’t send any full-range commands on the first run. I powered it up and checked one stage at a time: ESP32 boot, OLED initialization, TCA9548A communication, encoder input, WiFi connection, then the A4988 control signals. Only after all of that did I move Rail 1 in small increments, 1, 5, 10, 20, 50, and the motor followed every one of them.
The test passed. The ESP32 drove the A4988 over GPIO 12 and 13, the A4988 drove the 24V stepper, and the existing three-channel UI kept working the whole time. Rail 1 moved from both the EC11 knob and the web page, with its value on OLED 1.
The board now sits at the centre of two branches: ESP32 ── TCA9548A ── 3× (OLED + EC11) on one side, and ESP32 ── A4988 ── 42mm stepper motor on the other.
Rails 2 and 3 are still independent channels with nothing attached, left free for future motors or other actuators.
Safety Notes
- The motor and logic supplies stay separate. 24V only ever reaches the A4988’s motor input; the ESP32 supplies the logic rail.
- The grounds are common, so STEP and DIRECTION share a reference with the driver.
- The electrolytic went in with the right polarity, positive to VMOT and negative to GND, and it is rated 50V on a 24V rail.
- Never connect or disconnect the motor while the driver is powered. The inductive load throws voltage transients that will kill the driver.
- Set the A4988’s current limit to the motor’s rated current before running it for any length of time.
What’s Next
The single-motor test is the template for a three-axis rig:
Rail 1 → A4988 → Motor 1
Rail 2 → A4988 → Motor 2
Rail 3 → A4988 → Motor 3
Three A4988s, three motors, same software structure.
Full Code
/*
* ESP32 + TCA9548A + 3x (OLED + EC11)
* + WiFi web control
* + A4988 stepper motor
*
* Rail 1 → A4988 → Stepper Motor
* Rail 2 → independent control variable
* Rail 3 → independent control variable
*
* A4988:
* STEP GPIO 13
* DIRECTION GPIO 12
* SLEEP 3.3V
* RESET 3.3V
* MS1 GND
* MS2 GND
* MS3 GND
* ENABLE GND
*
* Motor power:
* VMOT → external 24V
* GND → 24V supply GND
*
* ESP32 GND and 24V supply GND are common
*/
#include <WiFi.h>
#include <WebServer.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
// ==================== WiFi ====================
const char* ssid = "YOUR_WIFI_SSID";
const char* password = "YOUR_WIFI_PASSWORD";
// ==================== I2C ====================
#define SDA_PIN 21
#define SCL_PIN 22
#define TCA_ADDR 0x70
// ==================== Encoders ====================
#define ENC1_A 25
#define ENC1_B 18
#define ENC2_A 26
#define ENC2_B 27
#define ENC3_A 4
#define ENC3_B 16
// ==================== A4988 ====================
#define A4988_DIR 12
#define A4988_STEP 13
// ==================== OLED ====================
Adafruit_SH1106G disp1(128, 64, &Wire, -1);
Adafruit_SH1106G disp2(128, 64, &Wire, -1);
Adafruit_SH1106G disp3(128, 64, &Wire, -1);
// ==================== Web server ====================
WebServer server(80);
// ==================== Rail state ====================
int rail1Val = 0;
int rail2Val = 0;
int rail3Val = 0;
// ==================== Encoder state machine ====================
static const int8_t KNOB_DIR[16] = {
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0
};
volatile uint8_t encState[3] = {0, 0, 0};
volatile int8_t encDelta[3] = {0, 0, 0};
// ==================== A4988 motor control ====================
long currentStep = 0; // software position
long targetStep = 0; // position requested by Rail 1
const long MAX_MOTOR_STEPS = 1000; // Rail 1 = 100 → 1000 steps
const unsigned long STEP_INTERVAL_US = 1000; // smaller = faster
unsigned long lastStepMicros = 0;
// ==================== Web page ====================
const char htmlPage[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,user-scalable=yes">
<title>Rail Controller</title>
<style>
:root{--bg:#000;--text:#f5f5f7;--card-bg:rgba(28,28,30,0.65);--slider-track:#3a3a3c;--slider-thumb:#0a84ff;--border:rgba(255,255,255,0.15);--shadow:rgba(0,0,0,0.5);}
body.light{--bg:#f2f2f7;--text:#1c1c1e;--card-bg:rgba(255,255,255,0.7);--slider-track:#c7c7cc;--slider-thumb:#007aff;--border:rgba(0,0,0,0.1);--shadow:rgba(0,0,0,0.08);}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background-color:var(--bg);color:var(--text);margin:0;padding:20px;display:flex;justify-content:center;align-items:center;min-height:100vh;transition:background-color .3s ease,color .3s ease;}
.container{width:100%;max-width:480px;background:var(--card-bg);backdrop-filter:blur(30px);-webkit-backdrop-filter:blur(30px);border-radius:32px;padding:30px 28px;box-shadow:0 20px 50px var(--shadow);border:1px solid var(--border);}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:25px;}
h1{font-size:22px;font-weight:600;margin:0;letter-spacing:-.4px;}
.theme-toggle{background:rgba(255,255,255,.15);border:none;border-radius:50%;width:42px;height:42px;font-size:18px;cursor:pointer;color:var(--text);display:flex;align-items:center;justify-content:center;transition:transform .2s;}
.theme-toggle:hover{transform:scale(1.08);}
.slider-group{margin:20px 0;}
.slider-group label{display:flex;align-items:center;justify-content:space-between;font-size:15px;font-weight:500;gap:8px;}
.slider-group span{min-width:50px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;}
input[type="range"]{-webkit-appearance:none;appearance:none;width:100%;height:6px;border-radius:3px;background:var(--slider-track);outline:none;margin:12px 0;}
input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:24px;height:24px;border-radius:50%;background:var(--slider-thumb);border:2px solid rgba(255,255,255,.3);cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.3);}
input[type="range"]::-moz-range-thumb{width:22px;height:22px;border-radius:50%;background:var(--slider-thumb);border:2px solid rgba(255,255,255,.3);cursor:pointer;}
.info{margin-top:25px;font-size:13px;opacity:.75;text-align:center;font-weight:500;line-height:1.6;}
.status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:#30d158;margin-right:6px;vertical-align:middle;animation:pulse 2s infinite;}
@keyframes pulse{0%,100%{opacity:1;}50%{opacity:.4;}}
</style>
</head>
<body class="dark">
<div class="container">
<div class="header"><h1>Rail Control</h1><button class="theme-toggle" id="theme-toggle">☀️</button></div>
<div class="slider-group"><label>Rail 1 <span id="rail1-val">0</span></label><input id="rail1" type="range" min="0" max="100" value="0"></div>
<div class="slider-group"><label>Rail 2 <span id="rail2-val">0</span></label><input id="rail2" type="range" min="0" max="100" value="0"></div>
<div class="slider-group"><label>Rail 3 <span id="rail3-val">0</span></label><input id="rail3" type="range" min="0" max="100" value="0"></div>
<div class="info" id="info"><span class="status-dot"></span>Rail 1: 0 | Rail 2: 0 | Rail 3: 0</div>
</div>
<script>
let R1 = 0, R2 = 0, R3 = 0;
let lastInteraction = 0;
const elR1 = document.getElementById('rail1');
const elR2 = document.getElementById('rail2');
const elR3 = document.getElementById('rail3');
const elR1v = document.getElementById('rail1-val');
const elR2v = document.getElementById('rail2-val');
const elR3v = document.getElementById('rail3-val');
const info = document.getElementById('info');
function updateUI(){
if(document.activeElement !== elR1) elR1.value = R1;
if(document.activeElement !== elR2) elR2.value = R2;
if(document.activeElement !== elR3) elR3.value = R3;
elR1v.textContent = R1;
elR2v.textContent = R2;
elR3v.textContent = R3;
info.innerHTML = '<span class="status-dot"></span>' +
'Rail 1: ' + R1 + ' | ' +
'Rail 2: ' + R2 + ' | ' +
'Rail 3: ' + R3;
}
function sendRails(){
lastInteraction = Date.now();
fetch('/rail?value=' + Math.round(R1) + ',' + Math.round(R2) + ',' + Math.round(R3)).catch(()=>{});
}
elR1.addEventListener('input', function(){ R1 = Number(this.value); updateUI(); sendRails(); });
elR2.addEventListener('input', function(){ R2 = Number(this.value); updateUI(); sendRails(); });
elR3.addEventListener('input', function(){ R3 = Number(this.value); updateUI(); sendRails(); });
setInterval(function(){
if(Date.now() - lastInteraction < 800) return;
fetch('/get').then(r => r.json()).then(d => {
if(d.r1 !== R1 || d.r2 !== R2 || d.r3 !== R3){
R1 = d.r1; R2 = d.r2; R3 = d.r3;
updateUI();
}
}).catch(()=>{});
}, 400);
const themeToggle = document.getElementById('theme-toggle');
const body = document.body;
const savedTheme = localStorage.getItem('theme');
if(savedTheme === 'light'){
body.classList.remove('dark');
body.classList.add('light');
themeToggle.innerHTML = '🌙';
}
themeToggle.addEventListener('click', function(){
if(body.classList.contains('dark')){
body.classList.remove('dark');
body.classList.add('light');
themeToggle.innerHTML = '🌙';
localStorage.setItem('theme', 'light');
} else {
body.classList.remove('light');
body.classList.add('dark');
themeToggle.innerHTML = '☀️';
localStorage.setItem('theme', 'dark');
}
});
updateUI();
</script>
</body>
</html>
)rawliteral";
// ==================== TCA9548A ====================
bool tcaSelect(uint8_t ch) {
for (int i = 0; i < 3; i++) {
Wire.beginTransmission(TCA_ADDR);
Wire.write(1 << ch);
if (Wire.endTransmission() == 0) return true;
delayMicroseconds(100);
}
return false;
}
// ==================== OLED draw ====================
void drawOne(Adafruit_SH1106G &disp, const char* label, int value) {
disp.clearDisplay();
disp.setTextSize(1);
disp.setTextColor(SH110X_WHITE);
disp.setCursor(0, 0);
disp.print(label);
disp.setTextSize(2);
disp.setCursor(0, 14);
disp.print(value);
disp.print(" %");
disp.drawRect(0, 46, 128, 14, SH110X_WHITE); // frame
int fillW = map(value, 0, 100, 0, 126); // progress bar
if (fillW > 0) disp.fillRect(1, 47, fillW, 12, SH110X_WHITE);
disp.display();
}
// ==================== Refresh all three OLEDs ====================
void refreshAll() {
if (tcaSelect(0)) drawOne(disp1, "Rail 1", rail1Val);
if (tcaSelect(1)) drawOne(disp2, "Rail 2", rail2Val);
if (tcaSelect(2)) drawOne(disp3, "Rail 3", rail3Val);
}
// ==================== Encoder ISRs ====================
void IRAM_ATTR isrEnc1() {
uint8_t s = (digitalRead(ENC1_A) << 1) | digitalRead(ENC1_B);
encState[0] = ((encState[0] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[0]];
if (d) encDelta[0] += d;
}
void IRAM_ATTR isrEnc2() {
uint8_t s = (digitalRead(ENC2_A) << 1) | digitalRead(ENC2_B);
encState[1] = ((encState[1] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[1]];
if (d) encDelta[1] += d;
}
void IRAM_ATTR isrEnc3() {
uint8_t s = (digitalRead(ENC3_A) << 1) | digitalRead(ENC3_B);
encState[2] = ((encState[2] << 2) | s) & 0x0F;
int8_t d = KNOB_DIR[encState[2]];
if (d) encDelta[2] += d;
}
// ==================== Update A4988 target ====================
void updateMotorTarget() {
targetStep = map(rail1Val, 0, 100, 0, MAX_MOTOR_STEPS);
}
// ==================== Non-blocking stepper control ====================
void updateMotor() {
if (currentStep == targetStep) return;
unsigned long now = micros();
if (now - lastStepMicros < STEP_INTERVAL_US) return;
lastStepMicros = now;
// direction
if (targetStep > currentStep) digitalWrite(A4988_DIR, HIGH);
else digitalWrite(A4988_DIR, LOW);
// STEP pulse
digitalWrite(A4988_STEP, HIGH);
delayMicroseconds(3);
digitalWrite(A4988_STEP, LOW);
// update software position
if (targetStep > currentStep) currentStep++;
else currentStep--;
}
// ==================== Handle encoders ====================
void handleEncoders() {
int8_t d[3];
noInterrupts();
d[0] = encDelta[0]; encDelta[0] = 0;
d[1] = encDelta[1]; encDelta[1] = 0;
d[2] = encDelta[2]; encDelta[2] = 0;
interrupts();
bool changed = false;
// -------------------- Rail 1 --------------------
if (d[0] != 0) {
rail1Val = constrain(rail1Val + d[0], 0, 100);
updateMotorTarget();
changed = true;
}
// -------------------- Rail 2 --------------------
if (d[1] != 0) {
rail2Val = constrain(rail2Val + d[1], 0, 100);
changed = true;
}
// -------------------- Rail 3 --------------------
if (d[2] != 0) {
rail3Val = constrain(rail3Val + d[2], 0, 100);
changed = true;
}
if (changed) refreshAll();
}
// ==================== HTTP root ====================
void handleRoot() {
server.send_P(200, "text/html; charset=utf-8", htmlPage);
}
// ==================== HTTP rail ====================
void handleRail() {
if (server.hasArg("value")) {
String val = server.arg("value");
int p1 = val.indexOf(',');
int p2 = val.indexOf(',', p1 + 1);
if (p1 > 0 && p2 > p1) {
rail1Val = constrain(val.substring(0, p1).toInt(), 0, 100);
rail2Val = constrain(val.substring(p1 + 1, p2).toInt(), 0, 100);
rail3Val = constrain(val.substring(p2 + 1).toInt(), 0, 100);
updateMotorTarget();
refreshAll();
}
}
server.send(200, "text/plain", "OK");
}
// ==================== HTTP get ====================
void handleGet() {
char buf[100];
snprintf(buf, sizeof(buf),
"{\"r1\":%d,\"r2\":%d,\"r3\":%d,\"currentStep\":%ld,\"targetStep\":%ld}",
rail1Val, rail2Val, rail3Val, currentStep, targetStep);
server.send(200, "application/json", buf);
}
// ==================== Setup ====================
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n=== ESP32 Rail Controller ===");
// --- A4988 GPIO ---
pinMode(A4988_DIR, OUTPUT);
pinMode(A4988_STEP, OUTPUT);
digitalWrite(A4988_DIR, LOW);
digitalWrite(A4988_STEP, LOW);
Serial.println("A4988 GPIO initialized.");
// --- I2C ---
Wire.begin(SDA_PIN, SCL_PIN);
Wire.setClock(100000);
delay(100);
// --- OLED self-test ---
Adafruit_SH1106G* disps[3] = { &disp1, &disp2, &disp3 };
for (int ch = 0; ch < 3; ch++) {
if (!tcaSelect(ch)) {
Serial.printf("!! tcaSelect(%d) FAIL\n", ch);
continue;
}
delay(20);
if (!disps[ch]->begin(0x3C, true)) {
Serial.printf("!! OLED %d begin FAIL\n", ch + 1);
} else {
Serial.printf("OK OLED %d\n", ch + 1);
// self-test: big number
disps[ch]->clearDisplay();
disps[ch]->setTextSize(5);
disps[ch]->setTextColor(SH110X_WHITE);
char num[2] = { (char)('1' + ch), 0 };
disps[ch]->setCursor(50, 12);
disps[ch]->print(num);
disps[ch]->display();
}
}
delay(800);
// --- Encoders ---
const uint8_t aPin[3] = {ENC1_A, ENC2_A, ENC3_A};
const uint8_t bPin[3] = {ENC1_B, ENC2_B, ENC3_B};
for (int i = 0; i < 3; i++) {
pinMode(aPin[i], INPUT_PULLUP);
pinMode(bPin[i], INPUT_PULLUP);
encState[i] = (digitalRead(aPin[i]) << 1) | digitalRead(bPin[i]);
}
attachInterrupt(digitalPinToInterrupt(ENC1_A), isrEnc1, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC1_B), isrEnc1, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC2_A), isrEnc2, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC2_B), isrEnc2, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC3_A), isrEnc3, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC3_B), isrEnc3, CHANGE);
// --- First frame ---
refreshAll();
// --- WiFi ---
WiFi.mode(WIFI_STA);
WiFi.setSleep(false); // keep WiFi from interfering with I2C
WiFi.setTxPower(WIFI_POWER_11dBm); // keep the TX spike from resetting the board
Serial.printf("Connecting to %s ", ssid);
WiFi.begin(ssid, password);
uint32_t t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 20000) {
delay(500);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
Serial.print("WiFi OK. IP: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("WiFi failed");
}
delay(300);
refreshAll(); // refresh once more after WiFi settles
// --- HTTP routes ---
server.on("/", handleRoot);
server.on("/rail", handleRail);
server.on("/get", handleGet);
server.begin();
Serial.println("HTTP server on port 80");
Serial.println("Ready.");
Serial.println();
Serial.println("Rail 1 controls A4988.");
Serial.println("Rail 1 = 0 -> 0 step");
Serial.println("Rail 1 = 100 -> 1000 step");
}
// ==================== Loop ====================
uint32_t lastRefresh = 0;
void loop() {
server.handleClient(); // WiFi
handleEncoders(); // three knobs
updateMotor(); // A4988
// refresh OLEDs every 3 seconds (against display drift)
if (millis() - lastRefresh > 3000) {
lastRefresh = millis();
refreshAll();
}
delay(1);
}
