系统提示 // 建议使用电脑访问,桌面模式效果最佳
ESP32 + A4988 24V 步进电机控制

ESP32 + A4988 24V 步进电机控制

我把 HSV 三个颜色通道改成了通用 Rail 通道,Rail 1 接 A4988 驱动 24V 步进电机;电机转的时候,编码器和网页照样能用。

年份

2026年9月

类型

lab

分类

嵌入式系统

工具

ESP32 · A4988 步进驱动器 · 24V 步进电机 · SH1106 1.3寸 OLED + EC11 一体化模块 ×3 · TCA9548A

#ESP32#WiFi#EC11 Encoder#OLED#步进电机#A4988#运动控制#Web Server

我做了什么

HSV 那套东西本质上是调色练习:三个通道、三个旋钮、三块屏。这次我把颜色拿掉,在末端挂了一台电机。

三个 HSV 通道变成了三个通用通道,Rail 1 到 Rail 3,量程还是 0–100,还是各自由一个编码器和网页滑块驱动。变的只是末端接的东西。我把 Rail 1 接到 A4988 驱动器和一台 24V 步进电机上,而这次真正想弄清楚的是:电机在转的时候,系统其他部分还跑不跑得动。结论是跑得动:GPIO 13 在往外发脉冲的同时,OLED 照常刷新、编码器照常计数、网页照常响应。这才是这次测试的重点。

新增的引脚只有两个:GPIO 12 走方向,GPIO 13 走步进。SLEEP 和 RESET 接 3.3V,三个细分引脚和 ENABLE 接地,Rail 2 和 Rail 3 目前没接任何东西。

硬件

组件 数量 用途
ESP32 1 主控制器
A4988 步进驱动器 1 步进电机驱动
24V 步进电机 1 执行机构
24V 外部电源 1 电机供电
47µF / 50V 电解电容 1 VMOT 电源滤波
TCA9548A 1 I2C 多路复用器
1.3 寸 SH1106 OLED + EC11 一体化模块 3 Rail 显示与调节

电机单独用一路 24V 供电,ESP32 只负责发逻辑信号。

ESP32 GPIO 功能
GPIO 21 I2C SDA
GPIO 22 I2C SCL
GPIO 25 编码器 1 A 相
GPIO 18 编码器 1 B 相
GPIO 26 编码器 2 A 相
GPIO 27 编码器 2 B 相
GPIO 4 编码器 3 A 相
GPIO 16 编码器 3 B 相
GPIO 12 A4988 DIRECTION
GPIO 13 A4988 STEP

除这两个新脚之外,其余接线和 HSV 那套完全一致。

A4988 接线

A4988 引脚 接法 功能
DIRECTION ESP32 GPIO 12 电机方向
STEP ESP32 GPIO 13 步进脉冲
SLEEP 3.3V 保持驱动器唤醒
RESET 3.3V 解除复位
MS1 GND 整步模式
MS2 GND 整步模式
MS3 GND 整步模式
ENABLE GND 驱动器常使能

A4988 有两个电源域,把这两个域分干净是关键。VDD 接 ESP32 的 3.3V,供逻辑部分。VMOT 接外部 24V,供电机部分。24V 的负极接 A4988 电机侧 GND,ESP32 的 GND 也接到同一个节点上,这样 STEP 和 DIRECTION 跟驱动器共参考地。24V 电源绝不碰 ESP32 的 3.3V 或 5V 引脚。

VMOT 输入端就近挂了一颗 47µF / 50V 电解电容,正极接 VMOT,负极接 GND。它给电机电源提供本地储能,压住驱动器开关动作引起的电压波动。24V 的电源上用 50V 耐压,余量足够。

电机四根线、两个线圈:1A/1B 是线圈 A,2A/2B 是线圈 B。线色因电机而异,可靠的办法是用万用表量:同一个线圈的两根线之间有可测电阻,不同线圈之间是开路。

第一次测试我故意用整步,MS1/MS2/MS3 全部接地。整步下 STEP 脉冲数和位移的对应关系最直观,第一次测试要的就是这个。等以后真的需要更平滑或者更精细的定位,再上细分也不迟。

Rail 架构

原来的 HSV 变量 hVal、sVal、vVal 改成了 rail1Val、rail2Val、rail3Val。每个 Rail 量程 0–100。编码器 1 调 Rail 1,编码器 2 调 Rail 2,编码器 3 调 Rail 3,网页滑块写的是同一组值,每块 OLED 显示自己的 Rail。三个通道从此和颜色无关。

Rail 1 线性映射到目标步数:

Rail 1 = 0   → 0 步
Rail 1 = 10  → 100 步
Rail 1 = 50  → 500 步
Rail 1 = 100 → 1000 步

1000 步这个上限是测试值,不等于电机转一整圈。实际转多少取决于电机的步距角和细分设置。

电机软件

干活的是 STEP 和 DIRECTION 两个引脚。固件里维护两个位置:

long currentStep;
long targetStep;

targetStep 是 Rail 1 要求的位置,currentStep 是软件认为电机现在待的位置。两者不一致时,控制器就发 STEP 脉冲直到追上,方向由哪边在前决定。

整个过程不阻塞主循环。每一轮依次跑 web server、编码器处理、走一步(间隔 1000µs)、OLED 刷新,然后重来。电机在动的这段时间里,网页和另外两个编码器一直有响应。这个结构以后接三个 A4988 也不用重写。

网页界面

页面沿用了 HSV 那套布局,只是把颜色控件换成了三个 Rail 滑块。在浏览器里拖 Rail 1,效果和转编码器 1 一样,都是改电机的目标位置。/get 端点返回当前三个 Rail 值以及 currentStep、targetStep,浏览器定时同步。

测试与结果

第一轮我没给满量程指令。上电后逐项确认:ESP32 启动、OLED 初始化、TCA9548A 通信、编码器输入、WiFi 连接,最后才是 A4988 的控制信号。这些都过了,我才让 Rail 1 小步往上加:1、5、10、20、50。每一步电机都跟上了。

测试通过。ESP32 通过 GPIO 12/13 驱动 A4988,A4988 驱动 24V 步进电机,原来的三通道界面全程正常工作。Rail 1 既能用 EC11 旋钮调,也能用网页调,数值显示在第一块 OLED 上。

板子现在处在两条支路的中心:一边是 ESP32 ── TCA9548A ── 3 个(OLED + EC11),另一边是 ESP32 ── A4988 ── 42mm 步进电机。

Rail 2 和 Rail 3 还是两个空着的独立通道,留给以后的电机或其他执行器。

安全注意

  • 电机电源和逻辑电源分开。24V 只进 A4988 的电机供电口,逻辑供电由 ESP32 提供。
  • 两侧共地,STEP 和 DIRECTION 才跟驱动器有共同的参考。
  • 电解电容极性接对:正极接 VMOT,负极接 GND。24V 电源上用 50V 耐压的。
  • 驱动器通电时不要插拔电机。感性负载会产生电压尖峰,足以打坏驱动器。
  • 长时间跑之前,先把 A4988 的电流限制调到电机的额定电流。

下一步

单电机这次测试就是三轴系统的模板:

Rail 1 → A4988 → 电机 1
Rail 2 → A4988 → 电机 2
Rail 3 → A4988 → 电机 3

三个 A4988、三台电机,软件结构不用改。

完整代码

/*
 * ESP32 + TCA9548A + 3× (OLED + EC11)
 * + WiFi 网页控制
 * + A4988 步进电机
 *
 * Rail 1 → A4988 → 步进电机
 * Rail 2 → 独立控制变量
 * Rail 3 → 独立控制变量
 *
 * A4988:
 * STEP      GPIO 13
 * DIRECTION GPIO 12
 * SLEEP     3.3V
 * RESET     3.3V
 * MS1       GND
 * MS2       GND
 * MS3       GND
 * ENABLE    GND
 *
 * 电机电源:
 * VMOT → 外部 24V
 * GND  → 24V电源GND
 *
 * ESP32 GND 与 24V电源GND共地
 */

#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

// ==================== 三个旋转编码器 ====================
#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 状态 ====================
int rail1Val = 0;
int rail2Val = 0;
int rail3Val = 0;

// ==================== 编码器状态机 ====================
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 电机控制 ====================
long currentStep = 0;                 // 当前实际位置
long targetStep  = 0;                 // 目标位置

const long MAX_MOTOR_STEPS = 1000;    // Rail 1 = 100 → 1000 步

const unsigned long STEP_INTERVAL_US = 1000;  // 数值越小,电机越快

unsigned long lastStepMicros = 0;

// ==================== 网页 ====================
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 &nbsp;|&nbsp; Rail 2: 0 &nbsp;|&nbsp; 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 + ' &nbsp;|&nbsp; ' +
    'Rail 2: ' + R2 + ' &nbsp;|&nbsp; ' +
    '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 绘制 ====================
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);   // 外框
  int fillW = map(value, 0, 100, 0, 126);        // 进度条
  if (fillW > 0) disp.fillRect(1, 47, fillW, 12, SH110X_WHITE);

  disp.display();
}

// ==================== 刷新三个 OLED ====================
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);
}

// ==================== 编码器 ISR ====================
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;
}

// ==================== 更新 A4988 目标位置 ====================
void updateMotorTarget() {
  targetStep = map(rail1Val, 0, 100, 0, MAX_MOTOR_STEPS);
}

// ==================== 非阻塞步进电机控制 ====================
void updateMotor() {
  if (currentStep == targetStep) return;

  unsigned long now = micros();
  if (now - lastStepMicros < STEP_INTERVAL_US) return;
  lastStepMicros = now;

  // 设置方向
  if (targetStep > currentStep) digitalWrite(A4988_DIR, HIGH);
  else                          digitalWrite(A4988_DIR, LOW);

  // STEP 脉冲
  digitalWrite(A4988_STEP, HIGH);
  delayMicroseconds(3);
  digitalWrite(A4988_STEP, LOW);

  // 更新软件位置
  if (targetStep > currentStep) currentStep++;
  else                          currentStep--;
}

// ==================== 处理编码器 ====================
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 自检 ---
  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);
      // 自检:显示大号数字
      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);

  // --- 编码器 ---
  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);

  // --- 初始 OLED ---
  refreshAll();

  // --- WiFi ---
  WiFi.mode(WIFI_STA);
  WiFi.setSleep(false);                  // 防止 WiFi 干扰 I2C
  WiFi.setTxPower(WIFI_POWER_11dBm);     // 防止电流冲击导致复位
  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();   // WiFi 稳定后再刷一次

  // --- HTTP 路由 ---
  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();        // 三个旋钮
  updateMotor();           // A4988

  // 每 3 秒刷新 OLED
  if (millis() - lastRefresh > 3000) {
    lastRefresh = millis();
    refreshAll();
  }

  delay(1);
}