pinball_exp_rp5/drv_stepmotor.py
2026-06-11 19:37:42 +08:00

370 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Raspberry Pi 5 + M5Stack StepMotor Driver 模块驱动
=====================================================
本模块用于驱动 M5Stack StepMotor Driver 模块,控制最多三个步进电机,
驱动圆柱在水槽中直线移动。
本模块结合了两种控制方式:
1. I2C 通信(地址 0x27用于控制模块的使能/复位/配置
2. 硬件 PWMGPIO12用于产生步进脉冲通过调整脉冲频率控制电机速度
方向控制通过独立的 GPIO 引脚5/6/16实现三个电机共享同一路 PWM
但方向可以独立控制。
PWM 概念简介:
PWMPulse Width Modulation脉冲宽度调制是一种通过快速开关信号来
模拟不同电压输出的技术。在步进电机控制中PWM 的频率决定电机转速——
频率越高,脉冲越快,电机转动越快。
参考文档:
https://docs.m5stack.com/en/module/Stepmotor%20Driver%20Module13.2%20v1.1
依赖库smbus2I2C 通信库)
使用示例:
from drv_stepmotor import StepMotorDriver
with StepMotorDriver() as driver:
driver.set_speeds([0.02, -0.015, 0.01])
time.sleep(2)
"""
from __future__ import annotations # 启用 Python 未来的注解特性,允许在类型提示中使用字符串形式的类名(前向引用)
import math # 导入 math 模块,用于数学计算(如余弦函数 cos() 用于 S 曲线加减速、圆周率 pi
import threading # 导入 threading 模块,用于多线程操作(本模块预留,当前未直接使用)
import time # 导入 time 模块,用于延时和计时(如加减速过程中的 sleep 暂停)
from typing import List, Optional # 从 typing 模块导入 List列表类型和 Optional可选类型用于参数类型注解
from gpiozero import DigitalOutputDevice # 从 gpiozero 库导入 DigitalOutputDevice 类,用于控制树莓派 GPIO 引脚的电平输出(高/低)
from smbus2 import SMBus # 从 smbus2 库导入 SMBus 类,用于通过 I2C 总线与 M5Stack 步进电机模块通信
from rpi_hardware_pwm import HardwarePWM # 从 rpi_hardware_pwm 库导入 HardwarePWM 类,用于控制树莓派硬件 PWM 发生器
# ==================== I2C 寄存器地址常量 ====================
# 以下常量对应 M5Stack StepMotor Driver 模块内部寄存器的 I2C 地址,
# 参考自官方 C++ 实现文件 ref_stepmotor.cpp
MODULE_STEPMOTOR_ADDR = 0x27 # 模块的 I2C 从设备地址7 位地址 0x27所有 I2C 通信都发往此地址
STEPMOTOR_REG_IO_CFG = 0x03 # IO 配置寄存器地址,用于配置模块引脚的输入/输出功能
STEPMOTOR_REG_MICROSTEP = 0x01 # 微步进模式配置寄存器地址,设置电机的微步细分数
STEPMOTOR_REG_ENABLE = 0x01 # 使能控制寄存器地址(与微步进共用 0x01通过不同位控制不同功能
STEPMOTOR_REG_EXTIO = 0x00 # 外部 IO 寄存器地址,用于扩展输入输出
STEPMOTOR_REG_FAULT = 0x04 # 故障状态寄存器地址,读取过流/过热等错误状态
STEPMOTOR_REG_RESET = 0x05 # 复位寄存器地址,用于单独复位各个电机通道
class StepMotorError(RuntimeError):
"""自定义异常类,继承自 RuntimeError用于步进电机驱动相关的错误处理"""
pass # 类体为空,仅作为自定义异常类型的标识,不需要额外实现
class StepMotorDriver:
"""步进电机驱动器类,最多控制 3 个步进电机。
本类封装了 I2C 通信和硬件 PWM 的全部控制逻辑,提供启动、停止、调速、
方向控制等完整功能。三个步进电机共享同一路硬件 PWM产生步进脉冲
但每个电机的方向由独立的 GPIO 引脚控制。
重要设计说明:
- 三个电机共享同一路 PWM因为所有电机同时以相同频率步进仅方向可以不同
- I2C 只用于模块的使能/复位配置,不用于产生步进脉冲
- 步进脉冲由树莓派硬件 PWM 直接产生,不受 CPU 负载影响
构造函数参数说明:
- pwm_pins: 用于产生步进脉冲的 GPIO 引脚编号BCM 编号),默认为 GPIO12
- dir_pins: 用于控制方向信号的 GPIO 引脚编号列表,最多 3 个,默认 [5, 6, 16]
- steps_per_rev: 电机转一圈所需的步数(微步进),默认 200×32×14 = 89600
- mm_per_rev: 电机转一圈对应的直线移动距离(毫米),默认 π×20 ≈ 62.83 mm
- ramp_time: 加减速的斜坡时间(秒),默认 20 秒
"""
def __init__(
self,
pwm_pins: int = None, # PWM 引脚编号BCM 编号),默认为 12即 GPIO12
dir_pins: Optional[List[int]] = None, # 方向控制 GPIO 引脚编号列表,默认为 [5, 6, 16]
steps_per_rev: int = 200*32*14, # 电机转一圈的总步数(微步进后)
mm_per_rev: float = math.pi*20, # 电机转一圈对应的直线移动距离(毫米)
ramp_time: float = 20, # 加减速斜坡时间(秒)
) -> None:
# 保存所有配置参数为实例变量,供其他方法访问
self.pwm_pins = pwm_pins or 12 # 如果未传入 PWM 引脚编号,默认使用 GPIO12
self.dir_pins = dir_pins or [5, 6, 16] # 如果未传入方向引脚列表,默认使用 GPIO5、GPIO6、GPIO16
self.steps_per_rev = steps_per_rev # 每转总步数 = 200电机物理步数× 32微步数× 14减速比
self.mm_per_rev = mm_per_rev # 每转移动距离 = π × 20驱动轮直径单位 mm
self.max_speed = 0.1 # 最大速度限制(米/秒),防止电机速度过快导致失步或机械冲击
self.ramp_time = float(ramp_time) # 加减速斜坡时间(秒),强制转为 float 类型以确保精度
self.i2c_addr = MODULE_STEPMOTOR_ADDR # I2C 设备地址,使用预定义的常量 0x27
self.i2c_bus = SMBus(1) # 创建 I2C 总线对象,使用 I2C 总线 1树莓派 5 上 I2C-1 对应引脚 GPIO2/SDA、GPIO3/SCL
self.default_dir = [True, True, False] # 三个电机的默认方向:电机 0 和电机 1 正向,电机 2 反向
self._pwm = HardwarePWM(pwm_channel=0, hz=1, chip=0) # 创建硬件 PWM 对象:通道 0初始 1HzPWM 芯片 0
self._dir_pins = [DigitalOutputDevice(d, initial_value=False) for d in self.dir_pins] # 为每个方向引脚创建 GPIO 输出对象,初始值为低电平
self.current_speed = 0.0 # 当前速度(米/秒),初始化为 0 表示电机处于停止状态
self.set_dir(True) # 设置三个电机的初始方向为正向
def _write_byte(self, reg: int, value: int) -> None:
"""向模块的指定 I2C 寄存器写入一个字节的数据。
这是一个内部方法(以下划线开头),封装了 I2C 寄存器写入操作。
参数:
reg: 寄存器地址(如 0x01 使能寄存器、0x05 复位寄存器等)
value: 要写入的 8 位数据值0~255写入前会自动截断为低 8 位
异常:
StepMotorError: 如果 I2C 通信失败则抛出此异常
"""
value &= 0xFF # 截断高位:只保留低 8 位0~255确保写入一个字节
try:
self.i2c_bus.write_byte_data(self.i2c_addr, reg, value) # 通过 I2C 总线向指定寄存器的地址写入数据
except Exception as e:
raise StepMotorError(f"I2C write_byte failed: {e}") # 如果 I2C 操作异常,包装为自定义异常向上抛出
def _read_byte(self, reg: int) -> int:
"""读取模块指定 I2C 寄存器的一个字节数据。
这是一个内部方法(以下划线开头),封装了 I2C 寄存器读取操作。
参数:
reg: 寄存器地址
返回:
int: 读取到的 8 位数据值0~255
异常:
StepMotorError: 如果 I2C 通信失败则抛出此异常
"""
try:
self.i2c_bus.write_byte(self.i2c_addr, reg) # 先向 I2C 设备发送要读取的寄存器地址(写入操作选择寄存器)
data = self.i2c_bus.read_byte(self.i2c_addr) # 再从设备读取该寄存器的当前值(读取操作获取数据)
return data # 返回读取到的数据值
except Exception as e:
raise StepMotorError(f"I2C read_byte failed: {e}") # 如果 I2C 操作异常,包装为自定义异常向上抛出
def enable_motor(self):
"""使能电机:允许电机驱动芯片接收脉冲信号,电机可以正常转动。
通过修改使能寄存器(地址 0x01的第 4 位bit 4来实现
bit 4 = 0 表示使能bit 4 = 1 表示禁止。
操作流程:先读取寄存器的当前值 → 修改特定位 → 将新值写回寄存器。
这种"读-改-写"模式确保不影响寄存器中其他位的设置。
"""
reg_data = self._read_byte(STEPMOTOR_REG_ENABLE) # 读取使能寄存器(地址 0x01的当前值
reg_data &= 0xEF # 清除第 4 位bit 4 置 00xEF = 0b11101111bit 4 = 0 表示使能电机
self._write_byte(STEPMOTOR_REG_ENABLE, reg_data) # 将修改后的值写回使能寄存器(地址 0x01
def disable_motor(self):
"""禁止电机:阻止电机驱动芯片接收脉冲信号,电机停止转动。
通过修改使能寄存器(地址 0x01的第 4 位bit 4来实现
将其他位清零、仅保留 bit 4 的值。
注意:原代码使用 reg_data &= 0x10 仅保留 bit 4 并清除其他位,
这与标准的"将 bit 4 置 1"reg_data |= 0x10有所不同
但为保持逻辑不变,此处不修改代码。
"""
reg_data = self._read_byte(STEPMOTOR_REG_ENABLE) # 读取使能寄存器(地址 0x01的当前值
reg_data &= 0x10 # 将除第 4 位之外的所有位清零0x10 = 0b00010000仅保留 bit 4
self._write_byte(STEPMOTOR_REG_ENABLE, reg_data) # 将修改后的值写回使能寄存器(地址 0x01
def reset_motor(self, resmtr: int, enable: bool):
"""复位或释放指定的电机通道。
通过复位寄存器(地址 0x05的低 3 位分别控制三个电机:
bit 0 控制电机 0bit 1 控制电机 1bit 2 控制电机 2。
将某位置 1 表示复位该电机,置 0 表示释放。
参数:
resmtr: 电机索引0、1、2分别对应三个步进电机通道
enable: True 表示复位(将对应位设为 1False 表示释放(将对应位设为 0
"""
reg_data = self._read_byte(STEPMOTOR_REG_RESET) # 读取复位寄存器(地址 0x05的当前值
reg_data &= 0x07 # 仅保留低 3 位bit 0~2分别对应三个电机的复位状态高位清零
if not enable:
reg_data &= ~(0x01 << resmtr) # 如果 enable=False将对应电机的位清零~(1<<n) 将第 n 位取反后做 AND
else:
reg_data |= (0x01 << resmtr) # 如果 enable=True将对应电机的位置 1(1<<n) 左移 n 位得到只有第 n 位为 1 的掩码
self._write_byte(STEPMOTOR_REG_RESET, reg_data) # 将修改后的值写回复位寄存器(地址 0x05
def close(self) -> None:
"""关闭驱动器:停止所有电机、释放硬件资源。
按顺序执行:紧急停止 → I2C 禁能 → 所有电机通道释放 → 清理 GPIO/PWM/I2C 对象。
使用 try/except 确保即使某个清理步骤出错,后续步骤也能继续执行。
"""
self.emergency_stop() # 紧急停止:立即切断 PWM 输出,电机瞬间失去脉冲
self.disable_motor() # 通过 I2C 设置使能寄存器,禁止电机驱动芯片
for idx in range(3): # 循环三个电机通道(索引 0、1、2
self.reset_motor(idx, False) # 释放每个电机通道:将复位寄存器中对应位清 0
# 关闭 gpiozero 相关的 GPIO 设备
try:
self._pwm.close() # 关闭硬件 PWM 对象,释放 PWM 通道资源
self.i2c_bus.close() # 关闭 I2C 总线连接,释放总线资源
for d in self._dir_pins: # 遍历所有方向控制引脚对象
d.off() # 将方向 GPIO 引脚置为低电平(关闭输出)
d.close() # 关闭方向 GPIO 引脚对象,释放资源
except Exception:
pass # 忽略清理过程中的任何异常,确保程序能正常退出
def set_dir(self, dir: bool = True) -> None:
"""设置三个电机的整体方向。
注意:三个电机只能同时设置为相同的方向(全部正向或全部反向)。
每个电机的实际方向由 default_dir电机默认方向和传入的 dir 参数
通过异或XOR运算决定这样可以灵活设置每个电机的正反。
参数:
dir: True 表示正向False 表示反向
电机 n 的实际方向 = default_dir[n] XOR dir
异常:
StepMotorError: 如果电机正在运行current_speed ≠ 0则不能改变方向
"""
if self.current_speed == 0.0: # 检查电机是否处于停止状态,运行时禁止改变方向
for idx, dir_dev in enumerate(self._dir_pins): # 遍历三个方向控制引脚(索引 0、1、2
boolean_dir = self.default_dir[idx] ^ dir # 计算实际方向:默认方向 XOR 传入方向(异或运算)
if boolean_dir:
dir_dev.on() # 如果计算结果为 True将 GPIO 引脚置为高电平(电机正转)
else:
dir_dev.off() # 如果计算结果为 False将 GPIO 引脚置为低电平(电机反转)
else:
raise StepMotorError("Cannot change direction while motor is running") # 电机运行时改变方向会损坏驱动器
def start(self, speed_m_s: float) -> None:
"""启动电机,并使用 S 曲线加速到目标速度。
S 曲线加速原理:
使用余弦函数产生平滑的"S"形加速曲线,速度变化率(即加速度)
在启动和结束时较小,中间较大,从而减少机械冲击和电机失步风险。
S 曲线加速公式:
s = (cos(π × (1 - i/100)) + 1) / 2 × speed_m_s
其中 i 从 0 到 99 逐步增加:
- i=0 时cos(π×(1-0)) = cos(π) = -1s = (-1+1)/2 × speed = 0 × speed = 0起始速度为 0
- i=50 时cos(π×(1-0.5)) = cos(π/2) = 0s = (0+1)/2 × speed = 0.5 × speed中间速度为目标一半
- i=99 时cos(π×(1-0.99)) = cos(0.01π) ≈ 1s = (1+1)/2 × speed ≈ speed接近目标速度
整个过程中,加速度先增大后减小,形成"S"形曲线,相比线性加速更加平顺。
频率换算公式:
freq = s × steps_per_rev / (mm_per_rev / 1000)
推导过程:
- freq(Hz) = 每秒步数(脉冲数)
- 每秒步数 = 速度(m/s) × 步数/米
- 步数/米 = 步数/转 ÷ 米/转 = steps_per_rev ÷ (mm_per_rev / 1000)
- steps_per_rev = 200电机物理步数1.8°/步)× 32驱动器微步细分数× 14行星减速器减速比
- mm_per_rev = π × 20驱动轮直径 20mm= 62.83 mm/转
参数:
speed_m_s: 目标速度(米/秒),必须为正值
异常:
StepMotorError: 如果电机已经在运行中,则不能重复启动
"""
if self.current_speed == 0.0: # 仅在电机当前处于停止状态时才能启动
self._pwm.change_frequency(1) # 先将 PWM 频率设为最低的 1Hz避免从 0Hz 直接跳变
self._pwm.start(100) # 启动硬件 PWM 输出,占空比设为 100%(模块内部处理步进脉冲的生成)
for i in range(100): # 将加速过程分为 100 个步进点,从 i=0 到 i=99 逐步加速
# S 曲线加速公式:余弦函数值从 cos(π) = -1 逐渐变化到 cos(0.01π) ≈ 1
# 经 (cos + 1) / 2 映射后,系数从 0 逐渐变化到接近 1
s = (math.cos(math.pi * (1-i/100)) + 1) / 2 * speed_m_s # 计算当前步进点的瞬时目标速度(米/秒)
# 将速度(米/秒)换算为 PWM 频率Hz
# freq = s × steps_per_rev / (mm_per_rev / 1000)
# 除以 1000 因为 mm_per_rev 是毫米,需要转换为米
freq = max(s * self.steps_per_rev / (self.mm_per_rev / 1000), 1) # 换算频率max(...,1) 确保频率不低于 1Hz
self._pwm.change_frequency(freq) # 更新硬件 PWM 的频率,使电机以对应速度转动
time.sleep(self.ramp_time / 100) # 等待 ramp_time/100 秒,控制每步加速的持续时间
self.current_speed = speed_m_s # 加速完成后,将当前速度记录为目标速度
else:
raise StepMotorError("Motor is already running") # 如果电机已经运行,拒绝重复启动(必须先 stop
def stop(self) -> None:
"""停止电机,使用 S 曲线减速到零。
S 曲线减速公式:
s = (cos(π × i/100) + 1) / 2 × current_speed
其中 i 从 0 到 99 逐步增加:
- i=0 时cos(0) = 1s = (1+1)/2 × speed = 1 × speed保持当前速度开始减速
- i=50 时cos(π/2) = 0s = (0+1)/2 × speed = 0.5 × speed速度降到一半
- i=99 时cos(0.99π) ≈ -1s = (-1+1)/2 × speed = 0 × speed = 0减速到零
与 start() 中的加速曲线对称,减速曲线也是 S 形,确保停止过程平滑。
减速时间比加速时间短(使用 ramp_time/4 而不是 ramp_time/100
因为减速到零后电机即停止,不需要像加速那样缓慢进入。
"""
if self.current_speed != 0.0: # 只有在电机正在运行时才能执行停止操作
for i in range(100): # 将减速过程分为 100 个步进点,从 i=0 到 i=99 逐步减速
# S 曲线减速公式:余弦值从 cos(0) = 1 逐渐变化到 cos(0.99π) ≈ -1
# 经 (cos + 1) / 2 映射后,系数从 1 逐渐下降到接近 0
s = (math.cos(math.pi * i/100) + 1) / 2 * self.current_speed # 计算当前步进点的瞬时速度(米/秒)
freq = max(s * self.steps_per_rev / (self.mm_per_rev / 1000), 1) # 将速度换算为 PWM 频率,最小 1Hz
self._pwm.change_frequency(freq) # 更新硬件 PWM 频率,降低电机转速
time.sleep(self.ramp_time / 4 / 100) # 等待 ramp_time/400 秒,比加速步进快 4 倍
self._pwm.stop() # 减速完成后,完全停止硬件 PWM 输出
self._pwm.change_frequency(1) # 将 PWM 频率复位到 1Hz 默认值,为下一次启动做准备
self.current_speed = 0.0 # 将当前速度设为 0标记电机已停止
else:
raise StepMotorError("Motor is already stopped") # 如果电机已经停止,拒绝重复执行停止操作
def change_speed(self, speed_m_s: float) -> None:
"""在电机运行过程中,平滑地将速度切换到新目标值。
速度切换 S 曲线公式:
s = (cos(π × i/100) + 1) / 2 × (current_speed - speed_m_s) + speed_m_s
公式推导:
将减速公式推广到任意目标速度:
- 令 delta = (current_speed - speed_m_s),即当前速度与目标速度的差值
- 减速公式变为s = 余弦因子 × delta + speed_m_s
- i=0 时:因子=1s = 1×delta + speed_m_s = current_speed保持当前速度
- i=99 时因子≈0s = 0×delta + speed_m_s = speed_m_s达到目标速度
传入的速度可以是加速(目标 > 当前)或减速(目标 < 当前),
S 曲线都能平滑过渡。
参数:
speed_m_s: 新的目标速度(米/秒),可高于或低于当前速度
异常:
StepMotorError: 如果电机未运行,调用此方法会抛出异常,提示使用 start()
"""
if self.current_speed != 0.0: # 只有在电机正在运行时才能切换速度
for i in range(100): # 分 100 步进行速度过渡,从 i=0 到 i=99
# 速度切换公式:当前的 S 曲线因子从 1 过渡到 0
# 乘以速度差后加到目标速度上,实现从当前速度到目标速度的平滑过渡
s = (math.cos(math.pi * i/100) + 1) / 2 * (self.current_speed - speed_m_s) + speed_m_s
freq = max(s * self.steps_per_rev / (self.mm_per_rev / 1000), 1) # 将速度换算为 PWM 频率,最小 1Hz
self._pwm.change_frequency(freq) # 更新硬件 PWM 频率,改变电机转速
time.sleep(self.ramp_time / 4 / 100) # 等待 ramp_time/400 秒,控制每步过渡的持续时间
self.current_speed = speed_m_s # 速度过渡完成后,将当前速度更新为新目标速度
else:
raise StepMotorError("Motor is not running. Use start() to start the motor.") # 电机停止时不能用 change_speed
def emergency_stop(self) -> None:
"""紧急停止:立即切断 PWM 输出,不经过 S 曲线减速。
与 stop() 方法的区别:
- stop():使用 S 曲线平滑减速(约需 ramp_time/4 秒)
- emergency_stop():直接切断 PWM 输出,无减速过程(立即停止)
适用于紧急情况下的快速停机,但会产生较大的机械冲击。
"""
self._pwm.stop() # 立即停止硬件 PWM 输出,电机瞬间失去步进脉冲
self._pwm.change_frequency(1) # 将 PWM 频率复位到 1Hz 默认值,为下一次启动做准备
self.current_speed = 0.0 # 将当前速度设为 0标记电机已停止
def __enter__(self):
"""进入上下文管理器:当使用 'with StepMotorDriver() as driver:' 语法时自动调用
返回:
StepMotorDriver: 返回自身实例,供 with 语句块中的变量引用
"""
return self # 返回当前 StepMotorDriver 实例
def __exit__(self, exc_type, exc, tb):
"""退出上下文管理器:当 with 语句块执行完毕时自动调用
参数:
exc_type: 异常类型(如果语句块中发生了异常)
exc: 异常对象
tb: 异常回溯信息
"""
self.close() # 自动调用 close() 清理所有资源,确保即使发生异常也能释放硬件