上文《【let'sdo|2026年第1期】静音步进电机控制实践过程贴——ATOMS3R+TMC2209步进电机驱动测试-电子产品世界论坛》,使用ATOMS3R-CAM实现对步进电机的速度控制。本文,通过 HTTP 协议搭建网页控制界面,实现对步进电机滑台的远程精准操控。
1、测试环境主控板:ATOMS3R-CAM(核心为 ESP32-S3,自带 WiFi 功能)
固件版本:UIFLOW2
开发语言:Micropython
硬件外设:步进电机滑台模组、TMC2209 驱动模块

核心逻辑是利用 ESP32-S3 连接 WiFi 建立 HTTP 服务器,生成可视化网页控制界面;网页与主控板通过 HTTP 协议通信,传递控制指令,主控板解析指令后驱动步进电机滑台完成相应动作:
网页端:包含控制按键(快退 / 慢退 / 慢进 / 快进)和位置滑条的交互界面,按键按下时发送持续运动指令,松开则发送停止指令;拖动滑条时发送目标位置指令。
主控端:监听 HTTP 请求,解析指令后控制步进电机的方向、速度和位移,同时实时反馈当前位置和运动状态到网页。
运动控制:通过脉冲细分算法实现电机的加减速控制,保证滑台运动平稳;设置 0~30mm 行程边界保护,避免超程损坏设备。
完整代码如下:
import os, sys, io
import M5
from M5 import *
from machine import Pin
import time
import network
import socket
# ========== WiFi 配置 ==========
WIFI_SSID = "SSID" # 你的WiFi名称
WIFI_PASS = "PASS" # 你的WiFi密码
HTTP_PORT = 80
# ========== 硬件配置 ==========
PIN_DIR = 7
PIN_STEP = 6
PIN_EN = 5
MM_PER_PULSE = 0.0052 / 8
START_SPEED = 1.0
STROKE_MM = 30.0
# ========== 全局状态 ==========
motor_dir = None
motor_step = None
motor_en = None
current_position_mm = 0.0
move_direction = 1
move_state = "IDLE"
total_pulses = 0
accel_pulses = 0
const_pulses = 0
pulse_index = 0
speed_step = 0
current_half_period_us = 0
last_pulse_time_us = 0
continuous_mode = False
continuous_max_speed = 0.0
move_queue = []
http_socket = None
# ========== 网页(拖动时滑条不被覆盖) ==========
HTML_PAGE = """<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>滑台控制</title>
<style>
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif;margin:0;padding:16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);min-height:100vh;touch-action:manipulation}
.card{background:rgba(255,255,255,0.95);backdrop-filter:blur(10px);border-radius:20px;padding:24px;margin-bottom:16px;box-shadow:0 8px 32px rgba(0,0,0,0.15)}
h1{margin:0 0 20px 0;font-size:22px;color:#333;text-align:center;font-weight:700;letter-spacing:1px}
.status-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
.status-label{font-size:14px;color:#888;font-weight:500}
.status-badge{display:flex;align-items:center;gap:6px;font-size:14px;color:#666;font-weight:600}
.status-dot{width:8px;height:8px;border-radius:50%;background:#ccc;transition:background 0.3s}
.status-dot.active{background:#52c41a;box-shadow:0 0 6px #52c41a}
.pos-value{font-size:42px;font-weight:800;color:#222;text-align:center;margin:8px 0 16px 0;letter-spacing:-1px}
.pos-unit{font-size:16px;color:#999;font-weight:500;margin-left:2px}
.slider-wrap{margin-top:8px}
.slider-labels{display:flex;justify-content:space-between;font-size:12px;color:#aaa;margin-bottom:6px;font-weight:500}
input[type=range]{width:100%;height:36px;-webkit-appearance:none;appearance:none;background:transparent;cursor:pointer}
input[type=range]::-webkit-slider-runnable-track{width:100%;height:6px;background:linear-gradient(90deg,#e0e0e0,#e0e0e0);border-radius:3px}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:24px;height:24px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:50%;margin-top:-9px;box-shadow:0 2px 8px rgba(102,126,234,0.4);border:2px solid #fff;transition:transform 0.15s}
input[type=range]::-webkit-slider-thumb:active{transform:scale(1.2)}
input[type=range]::-moz-range-track{width:100%;height:6px;background:#e0e0e0;border-radius:3px}
input[type=range]::-moz-range-thumb{width:24px;height:24px;background:linear-gradient(135deg,#667eea,#764ba2);border-radius:50%;border:2px solid #fff;box-shadow:0 2px 8px rgba(102,126,234,0.4)}
.target-display{text-align:center;font-size:13px;color:#667eea;margin-top:8px;font-weight:600;min-height:20px}
.btn-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:4px}
.btn{height:56px;border:none;border-radius:14px;font-size:13px;font-weight:700;color:#fff;cursor:pointer;box-shadow:0 3px 0 rgba(0,0,0,0.15);user-select:none;-webkit-user-select:none;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1.2;transition:transform 0.05s,box-shadow 0.05s}
.btn:active{transform:translateY(3px);box-shadow:0 0 0 rgba(0,0,0,0.15)}
.btn .sub{font-size:10px;font-weight:500;opacity:0.85;margin-top:1px}
.fast{background:linear-gradient(135deg,#ff6b6b,#ee5a5a)}
.slow{background:linear-gradient(135deg,#feca57,#ff9f43)}
</style>
</head>
<body>
<h1>滑台控制器</h1>
<div class="card">
<div class="status-row">
<span class="status-label">当前位置</span>
<span class="status-badge">
<span class="status-dot" id="dot"></span>
<span id="state">空闲</span>
</span>
</div>
<div class="pos-value"><span id="pos">0.00</span><span class="pos-unit">mm</span></div>
<div class="slider-wrap">
<div class="slider-labels"><span>0 mm</span><span>30 mm</span></div>
<input type="range" id="slider" min="0" max="30" step="0.1" value="0"
oninput="onSliderInput()"
onchange="onSliderChange()"
ontouchstart="onSliderTouchStart()"
ontouchend="onSliderTouchEnd()"
onmousedown="onSliderMouseDown()"
onmouseup="onSliderMouseUp()">
<div class="target-display" id="targetVal">目标: 0.00 mm</div>
</div>
</div>
<div class="card btn-row">
<button class="btn fast" ontouchstart="ts('fast_bwd')" ontouchend="te()" onmousedown="md('fast_bwd')" onmouseup="mu()" onmouseleave="mu()">
<span>◄◄ 快退</span><span class="sub">按住运行</span>
</button>
<button class="btn slow" ontouchstart="ts('slow_bwd')" ontouchend="te()" onmousedown="md('slow_bwd')" onmouseup="mu()" onmouseleave="mu()">
<span>◄ 慢退</span><span class="sub">按住运行</span>
</button>
<button class="btn slow" ontouchstart="ts('slow_fwd')" ontouchend="te()" onmousedown="md('slow_fwd')" onmouseup="mu()" onmouseleave="mu()">
<span>慢进 ►</span><span class="sub">按住运行</span>
</button>
<button class="btn fast" ontouchstart="ts('fast_fwd')" ontouchend="te()" onmousedown="md('fast_fwd')" onmouseup="mu()" onmouseleave="mu()">
<span>快进 ►►</span><span class="sub">按住运行</span>
</button>
</div>
<script>
var touching = false;
var isDragging = false;
var dragTimeout = null;
var lastDragTime = 0;
var displayValue = 0.0;
var targetValue = 0.0;
var isAnimating = false;
function send(cmd) {
fetch('/?cmd=' + cmd).then(r => r.text()).then(t => update(t)).catch(e => {});
}
function update(t) {
let p = t.match(/POS:([-\\d.]+)/);
let s = t.match(/STATE:(\\w+)/);
if (s) {
let moving = s[1] === 'MOVING';
document.getElementById('state').innerText = moving ? '运动中' : '空闲';
document.getElementById('dot').className = 'status-dot' + (moving ? ' active' : '');
}
if (p) {
let val = parseFloat(p[1]);
document.getElementById('pos').innerText = val.toFixed(2);
if (!isDragging) {
targetValue = val;
if (!isAnimating) {
isAnimating = true;
requestAnimationFrame(animateSlider);
}
}
}
}
function animateSlider() {
if (isDragging) { isAnimating = false; return; }
let diff = targetValue - displayValue;
if (Math.abs(diff) < 0.005) {
displayValue = targetValue;
document.getElementById('slider').value = displayValue;
document.getElementById('targetVal').innerText = '目标: ' + displayValue.toFixed(2) + ' mm';
isAnimating = false;
return;
}
let step = diff * 0.25;
if (Math.abs(step) < 0.01) step = diff > 0 ? 0.01 : -0.01;
displayValue += step;
document.getElementById('slider').value = displayValue;
document.getElementById('targetVal').innerText = '目标: ' + displayValue.toFixed(2) + ' mm';
requestAnimationFrame(animateSlider);
}
function onSliderTouchStart() {
isDragging = true; isAnimating = false;
if (dragTimeout) clearTimeout(dragTimeout);
}
function onSliderTouchEnd() {
dragTimeout = setTimeout(function(){ isDragging = false; displayValue = parseFloat(document.getElementById('slider').value); }, 500);
}
function onSliderMouseDown() {
isDragging = true; isAnimating = false;
if (dragTimeout) clearTimeout(dragTimeout);
}
function onSliderMouseUp() {
dragTimeout = setTimeout(function(){ isDragging = false; displayValue = parseFloat(document.getElementById('slider').value); }, 500);
}
function onSliderInput() {
let v = parseFloat(document.getElementById('slider').value);
document.getElementById('targetVal').innerText = '目标: ' + v.toFixed(2) + ' mm';
lastDragTime = Date.now();
displayValue = v;
}
function onSliderChange() {
let v = parseFloat(document.getElementById('slider').value);
send('goto_' + v.toFixed(1));
isDragging = true; isAnimating = false;
if (dragTimeout) clearTimeout(dragTimeout);
dragTimeout = setTimeout(function(){ isDragging = false; displayValue = v; }, 2000);
}
function ts(cmd) { touching = true; send(cmd); }
function te() { touching = false; send('stop_smooth'); }
function md(cmd) { if (!touching) send(cmd); }
function mu() { if (!touching) send('stop_smooth'); }
displayValue = parseFloat(document.getElementById('slider').value) || 0;
setInterval(function(){
if (!isDragging && (Date.now() - lastDragTime) > 1000) send('status');
}, 300);
</script>
</body>
</html>"""
# ============================================================
# 核心运动函数
# ============================================================
def _calc_move_params(distance_mm, max_speed, accel):
global total_pulses, accel_pulses, const_pulses, speed_step, move_state
global pulse_index, current_half_period_us, last_pulse_time_us, move_direction
if distance_mm == 0:
return False
motor_dir.value(0 if distance_mm > 0 else 1)
move_direction = 1 if distance_mm > 0 else -1
distance_mm = abs(distance_mm)
total_pulses = int(distance_mm / MM_PER_PULSE)
if total_pulses == 0:
return False
accel_dist = (max_speed ** 2) / (2 * accel)
accel_pulses = int(accel_dist / MM_PER_PULSE)
if accel_pulses > total_pulses // 2:
accel_pulses = total_pulses // 2
const_pulses = total_pulses - 2 * accel_pulses
speed_step = (max_speed - START_SPEED) / accel_pulses if accel_pulses > 0 else 0
pulse_index = 0
current_half_period_us = int(1_000_000 / (START_SPEED / MM_PER_PULSE)) // 2
last_pulse_time_us = time.ticks_us()
move_state = "MOVING"
print(f"[TMC2209] 目标位移:{distance_mm:.3f}mm 总脉冲:{total_pulses} "
f"加速段:{accel_pulses} 匀速段:{const_pulses} 方向:{'+' if move_direction>0 else '-'}")
return True
def _send_one_pulse():
global pulse_index, current_half_period_us, last_pulse_time_us, move_state
global current_position_mm, move_direction, continuous_mode, continuous_max_speed
now = time.ticks_us()
elapsed = time.ticks_diff(now, last_pulse_time_us)
if elapsed < current_half_period_us * 2:
return False
motor_step.value(1)
time.sleep_us(max(current_half_period_us, 30))
motor_step.value(0)
if move_direction > 0:
current_position_mm += MM_PER_PULSE
else:
current_position_mm -= MM_PER_PULSE
last_pulse_time_us = time.ticks_us()
pulse_index += 1
# 边界保护
if move_direction > 0 and current_position_mm >= STROKE_MM - 0.001:
current_position_mm = min(current_position_mm, STROKE_MM)
if continuous_mode:
stop_decelerate()
else:
move_state = "IDLE"
return False if continuous_mode else True
if move_direction < 0 and current_position_mm <= 0.001:
current_position_mm = max(current_position_mm, 0.0)
if continuous_mode:
stop_decelerate()
else:
move_state = "IDLE"
return False if continuous_mode else True
if not continuous_mode and pulse_index >= total_pulses:
move_state = "IDLE"
return True
# 速度计算
if pulse_index < accel_pulses:
spd = min(START_SPEED + pulse_index * speed_step,
START_SPEED + accel_pulses * speed_step)
elif not continuous_mode and pulse_index >= total_pulses - accel_pulses:
decel_i = total_pulses - pulse_index - 1
spd = max(START_SPEED + decel_i * speed_step, START_SPEED)
else:
spd = continuous_max_speed if continuous_mode else (START_SPEED + accel_pulses * speed_step)
freq = spd / MM_PER_PULSE
current_half_period_us = max(int(1_000_000 / freq) // 2, 30)
return False
# ============================================================
# 对外接口
# ============================================================
def start_continuous(direction, max_speed, accel):
global move_state, move_direction, continuous_mode, continuous_max_speed
global total_pulses, accel_pulses, const_pulses, speed_step
global pulse_index, current_half_period_us, last_pulse_time_us
if move_state == "MOVING" and continuous_mode and move_direction == direction:
return
if move_state == "MOVING":
stop_immediate()
time.sleep_us(200)
move_queue.clear()
motor_en.value(0)
motor_dir.value(0 if direction > 0 else 1)
move_direction = direction
continuous_mode = True
continuous_max_speed = max_speed
accel_dist = (max_speed ** 2) / (2 * accel)
accel_pulses = int(accel_dist / MM_PER_PULSE)
if accel_pulses < 1:
accel_pulses = 1
total_pulses = 0x7FFFFFFF
const_pulses = total_pulses - 2 * accel_pulses
speed_step = (max_speed - START_SPEED) / accel_pulses
pulse_index = 0
current_half_period_us = int(1_000_000 / (START_SPEED / MM_PER_PULSE)) // 2
last_pulse_time_us = time.ticks_us()
move_state = "MOVING"
print(f"[TMC2209] 持续运动 方向:{'+' if direction>0 else '-'} 速度:{max_speed}mm/s")
def move_to(target_mm, max_speed, accel):
global move_queue
target_mm = max(0.0, min(STROKE_MM, float(target_mm)))
move_queue.append(("ABS", target_mm, max_speed, accel))
print(f"[MOVE] 加入队列: 绝对位置 {target_mm}mm")
def move_relative(distance_mm, max_speed, accel):
global move_queue
target = current_position_mm + distance_mm
target = max(0.0, min(STROKE_MM, target))
actual_dist = target - current_position_mm
if abs(actual_dist) > 0.005:
move_queue.append(("REL", actual_dist, max_speed, accel))
print(f"[MOVE] 相对运动 {actual_dist:.3f}mm")
else:
print(f"[MOVE] 已在边界,忽略运动")
def stop_immediate():
global move_queue, move_state, continuous_mode
move_queue.clear()
continuous_mode = False
if move_state == "MOVING":
move_state = "IDLE"
print(f"[STOP] 立即停止,当前位置: {current_position_mm:.3f}mm")
def stop_decelerate():
global total_pulses, accel_pulses, const_pulses, pulse_index, move_state
global move_queue, continuous_mode
move_queue.clear()
if move_state != "MOVING":
return
if not continuous_mode and pulse_index >= total_pulses - accel_pulses:
return
continuous_mode = False
if pulse_index < accel_pulses:
i = max(pulse_index, 1)
total_pulses = 2 * i
accel_pulses = i
const_pulses = 0
else:
total_pulses = pulse_index + accel_pulses
const_pulses = 0
print(f"[STOP] 减速停止,剩余脉冲: {total_pulses - pulse_index}")
def get_position():
return current_position_mm
def is_moving():
return move_state == "MOVING"
def home(max_speed=10.0, accel=40.0):
move_to(0.0, max_speed, accel)
# ============================================================
# WiFi & HTTP
# ============================================================
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
print(f"[WiFi] 正在连接 {WIFI_SSID}...")
wlan.connect(WIFI_SSID, WIFI_PASS)
timeout = 0
while not wlan.isconnected() and timeout < 30:
time.sleep(1)
timeout += 1
print(".", end="")
if wlan.isconnected():
ip = wlan.ifconfig()[0]
print(f"\n[WiFi] 已连接,IP地址: {ip}")
print(f"[WiFi] 请在浏览器访问: http://{ip}")
return ip
else:
print("\n[WiFi] 连接失败")
return None
def start_http_server(ip):
global http_socket
try:
addr = socket.getaddrinfo(ip, HTTP_PORT)[0][-1]
except:
addr = (ip, HTTP_PORT)
http_socket = socket.socket()
http_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
http_socket.bind(addr)
http_socket.listen(5)
http_socket.setblocking(False)
print(f"[HTTP] 服务器启动: http://{ip}:{HTTP_PORT}")
def handle_cmd(cmd):
print(f"[CMD] 收到命令: {cmd}")
if cmd == 'fast_fwd':
start_continuous(1, 20.0, 80.0)
elif cmd == 'slow_fwd':
start_continuous(1, 2.0, 20.0)
elif cmd == 'stop':
stop_immediate()
elif cmd == 'stop_smooth':
stop_decelerate()
elif cmd == 'slow_bwd':
start_continuous(-1, 2.0, 20.0)
elif cmd == 'fast_bwd':
start_continuous(-1, 20.0, 80.0)
elif cmd.startswith('goto_'):
try:
target = float(cmd.split('_')[1])
if move_state == "MOVING":
stop_immediate()
time.sleep_us(500)
move_to(target, 15.0, 60.0)
except Exception as e:
print(f"[CMD] goto解析错误: {e}")
elif cmd == 'status':
pass
else:
print(f"[CMD] 未知命令: {cmd}")
def handle_http():
global http_socket
if http_socket is None:
return
conn = None
try:
conn, addr = http_socket.accept()
conn.setblocking(True)
conn.settimeout(1.0)
request = b""
try:
request = conn.recv(1024)
except OSError as e:
if e.errno not in (11, 35):
print(f"[HTTP] recv错误: {e}")
return
except Exception as e:
print(f"[HTTP] recv异常: {e}")
return
if not request:
return
try:
req_str = request.decode('utf-8', 'ignore')
except:
req_str = str(request)
lines = req_str.split('\r\n')
if not lines or not lines[0]:
lines = req_str.split('\n')
if not lines or not lines[0]:
return
parts = lines[0].split(' ')
if len(parts) < 2:
return
path = parts[1]
cmd = None
if '?cmd=' in path:
cmd_part = path.split('?cmd=')[1]
cmd = cmd_part.split('&')[0].split(' ')[0]
if cmd:
handle_cmd(cmd)
resp_body = f"OK|POS:{current_position_mm:.2f}|STATE:{move_state}"
header = 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n'
conn.send((header + resp_body).encode())
else:
header = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n'
conn.send(header.encode())
page = HTML_PAGE.encode()
for i in range(0, len(page), 512):
conn.send(page[i:i+512])
except OSError as e:
if e.errno not in (11, 35):
print(f"[HTTP] Socket错误: {e}")
except Exception as e:
print(f"[HTTP] 错误: {e}")
finally:
if conn:
try:
conn.close()
except:
pass
# ============================================================
# 主程序
# ============================================================
def setup():
global motor_dir, motor_step, motor_en
M5.begin()
motor_dir = Pin(PIN_DIR, Pin.OUT)
motor_step = Pin(PIN_STEP, Pin.OUT)
motor_en = Pin(PIN_EN, Pin.OUT)
motor_en.value(0)
motor_step.value(0)
print("=" * 50)
print("[TMC2209] 滑台控制程序初始化完成")
print(f"[TMC2209] 行程: 0~{STROKE_MM}mm | 起点: 最左侧(0mm)")
print("=" * 50)
ip = connect_wifi()
if ip:
start_http_server(ip)
else:
print("[MAIN] WiFi未连接,仅支持本地控制")
def loop():
global move_queue
M5.update()
handle_http()
if move_state == "MOVING":
if _send_one_pulse():
print(f"[TMC2209] 移动完成,当前位置: {current_position_mm:.3f}mm")
time.sleep_ms(200)
elif move_queue:
motor_en.value(0)
task = move_queue.pop(0)
task_type = task[0]
if task_type == "ABS":
_, target_mm, max_speed, accel = task
distance_mm = target_mm - current_position_mm
if abs(distance_mm) < 0.01:
print(f"[TMC2209] 已在目标位置 {target_mm}mm")
else:
_calc_move_params(distance_mm, max_speed, accel)
else:
_, distance_mm, max_speed, accel = task
_calc_move_params(distance_mm, max_speed, accel)
else:
motor_en.value(1)
time.sleep_ms(10)
if __name__ == '__main__':
try:
setup()
while True:
loop()
except (Exception, KeyboardInterrupt) as e:
try:
motor_en.value(1)
print("[TMC2209] 异常中断,电机已安全禁用")
except Exception:
pass
try:
from utility import print_error_msg
print_error_msg(e)
except ImportError:
print("please update to latest firmware")3、电机驱动测试程序运行后,串口日志会输出 WiFi 连接状态,成功后显示网页地址(如http://192.168.31.100)。进入网页,按住“快进 / 慢进 / 快退 / 慢退”按键,滑台按对应方向和速度运动,松开按键后滑台平稳减速停止。拖动滑条上的滑块(或点击滑条任意位置),滑台自动运动到该位置。
我要赚赏金
