这些小活动你都参加了吗?快来围观一下吧!>>
电子产品世界 » 论坛首页 » DIY与开源设计 » 电子DIY » 【let'sdo|2026年第1期】静音步进电机控制实践过程贴——网页控制步进电

共1条 1/1 1 跳转至

【let'sdo|2026年第1期】静音步进电机控制实践过程贴——网页控制步进电机滑台

助工
2026-06-05 09:59:28     打赏

上文《【let'sdo|2026年第1期】静音步进电机控制实践过程贴——ATOMS3R+TMC2209步进电机驱动测试-电子产品世界论坛》,使用ATOMS3R-CAM实现对步进电机的速度控制。本文,通过 HTTP 协议搭建网页控制界面,实现对步进电机滑台的远程精准操控。

1、测试环境
  • 主控板:ATOMS3R-CAM(核心为 ESP32-S3,自带 WiFi 功能)

  • 固件版本:UIFLOW2

  • 开发语言:Micropython

  • 硬件外设:步进电机滑台模组、TMC2209 驱动模块

1780624607369627.jpg

2、原理

核心逻辑是利用 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)。进入网页,按住“快进 / 慢进 / 快退 / 慢退”按键,滑台按对应方向和速度运动,松开按键后滑台平稳减速停止。拖动滑条上的滑块(或点击滑条任意位置),滑台自动运动到该位置。





关键词: 滑台     步进电机    

共1条 1/1 1 跳转至

回复

匿名不能发帖!请先 [ 登陆 注册 ]