commit 4de19b217ea9d7d4f950d7dc3d4ab3176f3d79a2 Author: Frank14f Date: Thu Jun 11 19:37:42 2026 +0800 上传文件至 / diff --git a/README.md b/README.md new file mode 100644 index 0000000..293dcb1 --- /dev/null +++ b/README.md @@ -0,0 +1,671 @@ +# 树莓派 5 圆柱运动控制系统 + +> 本项目运行在 **Raspberry Pi 5**(树莓派 5)上,通过多种通信协议控制三个圆柱在水槽中的运动,并实时测量受力情况。 + +--- + +## 目录 + +1. [项目简介](#1-项目简介) +2. [整体架构](#2-整体架构) +3. [什么是树莓派(Raspberry Pi)?](#3-什么是树莓派raspberry-pi) +4. [GPIO 概念](#4-gpio-概念) +5. [通信协议简介](#5-通信协议简介) +6. [Python 环境搭建](#6-python-环境搭建) +7. [项目文件结构](#7-项目文件结构) +8. [如何运行](#8-如何运行) +9. [三个驱动模块详解](#9-三个驱动模块详解) +10. [signal_features.json 说明](#10-signal_featuresjson-说明) +11. [常见问题 FAQ](#11-常见问题-faq) +12. [参考资料](#12-参考资料) + +--- + +## 1. 项目简介 + +这是一个在 **Raspberry Pi 5** 上运行的控制系统,用于在水槽中控制三个圆柱的运动并测量受力。系统通过以下方式工作: + +- 三个**空心杯电机**(带编码器反馈)控制圆柱的**旋转** +- 三个**步进电机**控制圆柱的**直线移动** +- 一个**高精度 ADC**(模数转换器)测量应变片传来的电压信号,从而算出受力大小 + +--- + +## 2. 整体架构 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Raspberry Pi 5 │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ SPI 总线 │ │ I2C 总线 │ │ 硬件 PWM │ │ +│ │ /dev/spidev0.0│ │ /dev/i2c-1 │ │ GPIO12 输出 │ │ +│ └───────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │ +│ │ │ │ │ +└───────────┼───────────────────┼────────────────────────┼──────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ + │ ADS124S08 │ │ 4EncoderMotor │ │ StepMotor Driver │ + │ ADC 芯片 │ │ 电机模块 (I2C) │ │ (I2C + PWM) │ + │ (SPI 通信) │ │ 地址: 0x24 │ │ 地址: 0x27 │ + └──────┬───────┘ └────────┬─────────┘ └────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ + 应变片信号 空心杯电机 (×4) 步进电机 (×3) + (电压测量) (控制圆柱旋转) (控制圆柱平移) +``` + +--- + +## 3. 什么是树莓派(Raspberry Pi)? + +树莓派是一台只有**信用卡大小**的 Linux 计算机。它虽然小,但具备普通电脑的所有功能——有 CPU、内存、USB 接口、HDMI 视频输出、网络接口等。树莓派最特别的地方是它有一排**GPIO 引脚**,可以通过这些引脚直接控制外部电子设备(比如电机、传感器、灯光等)。 + +### 本项目使用的型号:Raspberry Pi 5 + +### 树莓派引脚图 + +树莓派 5 的 40 针 GPIO 排针布局如下(从上方俯视,SD 卡槽朝上): + +``` + 3.3V (1) (2) 5V + GPIO2 (SDA) (3) (4) 5V + GPIO3 (SCL) (5) (6) GND + GPIO4 (7) (8) GPIO14 (TXD) + GND (9) (10) GPIO15 (RXD) + GPIO17 (11)(12) GPIO18 + GPIO27 (13)(14) GND + GPIO22 (15)(16) GPIO23 + 3.3V (17)(18) GPIO24 + GPIO10(MOSI)(19)(20) GND + GPIO9(MISO)(21)(22) GPIO25 + GPIO11(SCLK)(23)(24) GPIO8 (CS) + GND (25)(26) GPIO7 (CS1) + GPIO0(IDSD)(27)(28) GPIO1 (IDSC) + GPIO5 (29)(30) GND + GPIO6 (31)(32) GPIO12 (PWM) + GPIO13 (33)(34) GND + GPIO19 (35)(36) GPIO16 + GPIO26 (37)(38) GPIO20 + GND (39)(40) GPIO21 +``` + +> 详细引脚功能说明请参考:[树莓派官方引脚文档](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html) + +--- + +## 4. GPIO 概念 + +### 什么是 GPIO? + +GPIO 是 **General Purpose Input Output**(通用输入输出引脚)的缩写。可以把 GPIO 引脚想象成树莓派身上伸出来的"小触手",每个触手可以: + +- **输出高电平(3.3V)或低电平(0V)**:相当于开或关一个开关 +- **检测外部电平是高还是低**:相当于感应外部有没有电信号 + +### 高电平和低电平 + +- **高电平(HIGH / 1 / 3.3V)**:引脚输出 3.3 伏特的电压,相当于"开" +- **低电平(LOW / 0 / 0V)**:引脚输出 0 伏特的电压,相当于"关" + +简单来说,树莓派通过把 GPIO 引脚设置为高电平或低电平,来控制外部设备。比如把某个引脚设为高电平,就等于告诉连接的设备"开始工作"。 + +### 本项目中使用的 GPIO 引脚 + +| 引脚 (BCM) | 功能 | 连接设备 | +|---|---|---| +| GPIO12 | PWM 输出(产生步进脉冲) | StepMotor 模块 | +| GPIO5 | 步进电机 0 方向控制 | StepMotor 模块 | +| GPIO6 | 步进电机 1 方向控制 | StepMotor 模块 | +| GPIO16 | 步进电机 2 方向控制 | StepMotor 模块 | +| GPIO22 | 电源阶段 1 控制(先供电) | ADC (ADS124S08) | +| GPIO17 | 电源阶段 1 控制(先供电) | ADC | +| GPIO24 | 电源阶段 2 控制(后供电) | ADC | +| GPIO27 | 电源阶段 2 控制(后供电) | ADC | +| GPIO23 | 保持低电平(初始化期间) | ADC | +| GPIO18 | 保持低电平(初始化期间) | ADC | +| GPIO25 | DRDY 输入(检测ADC数据就绪) | ADC | +| GPIO10 | SPI MOSI(主出从入) | ADC | +| GPIO9 | SPI MISO(主入从出) | ADC | +| GPIO11 | SPI SCLK(时钟线) | ADC | +| GPIO8 | SPI CS(片选,低电平选中) | ADC | +| GPIO2 | I2C SDA(数据线) | 电机模块 | +| GPIO3 | I2C SCL(时钟线) | 电机模块 | + +> BCM 编号是 Broadcom 芯片的 GPIO 编号方式,也是 Python 库(如 gpiozero)默认使用的编号方式。 + +--- + +## 5. 通信协议简介 + +### 5.1 SPI(Serial Peripheral Interface,串行外设接口) + +#### 是什么 + +SPI 是一种**同步串行通信协议**,用 **4 根线**连接主设备(这里是树莓派)和从设备(这里是 ADC 芯片)。 + +#### 四根线的比喻 + +把 SPI 通信想象成老师和学生对话: + +| 线名 | 全称 | 比喻 | +|---|---|---| +| **SCLK** | Serial Clock(串行时钟) | 老师用**节拍器**打拍子,所有人按这个节奏说话 | +| **MOSI** | Master Out Slave In(主出从入) | **老师对学生说话**的通道 | +| **MISO** | Master In Slave Out(主入从出) | **学生对老师说话**的通道 | +| **CS** | Chip Select(片选) | 老师**点名**:喊谁的名字(拉低电平),谁就说话 | + +> **CS 低电平有效**:当 CS 线从高电平变为低电平时,表示"我要跟你说话了";当 CS 线回到高电平时,表示"对话结束"。 + +#### 为什么 ADC 用 SPI? + +- **速度快**:SPI 时钟频率可达几十 MHz,适合高速数据读取 +- **全双工**:可以同时发送和接收数据(就像两个人可以同时说话和听) +- 高精度 ADC(如 ADS124S08)需要快速、可靠地传输 24 位数据,SPI 非常适合 + +#### 本项目中的 SPI + +- **主设备**:树莓派 5 +- **从设备**:ADS124S08 ADC 芯片 +- **总线路径**:`/dev/spidev0.0`(总线 0,设备 0) +- **时钟频率**:1 MHz(每秒传输 1,000,000 位数据) +- **SPI 模式**:模式 1(CPOL=0,CPHA=1) + +--- + +### 5.2 I2C(Inter-Integrated Circuit,内部集成电路) + +#### 是什么 + +I2C 是一种**同步串行通信协议**,只用 **2 根线**就可以连接多个设备。 + +#### 两根线的比喻 + +I2C 像学校的**广播系统**: + +| 线名 | 全称 | 比喻 | +|---|---|---| +| **SDA** | Serial Data(串行数据线) | 广播的**话筒和喇叭**,说话和听话都用这一根 | +| **SCL** | Serial Clock(串行时钟线) | 广播的**节拍信号**,控制说话的节奏 | + +所有设备都接在同一对线上,但每个设备都有一个唯一的**地址**(就像一个班每个学生有**学号**)。主机喊学号,对应学号的设备才应答;其他设备保持安静。 + +#### 为什么电机模块用 I2C? + +- **接线简单**:只需要两根线,可以挂多个设备(每个设备用不同地址区分) +- **设备数量**:理论上一个 I2C 总线上最多可以连接 127 个设备 +- 电机控制不需要极快的数据传输速度,I2C 完全够用 + +#### 本项目中的 I2C + +| 设备 | I2C 地址 | 用途 | +|---|---|---| +| 4EncoderMotor 模块 | **0x24** | 控制空心杯电机旋转 | +| StepMotor Driver 模块 | **0x27** | 配置步进电机(使能/复位等) | + +树莓派通过 `/dev/i2c-1`(I2C 总线 1)与这两个模块通信。 + +--- + +### 5.3 PWM(Pulse Width Modulation,脉冲宽度调制) + +#### 是什么 + +PWM 是一种通过**快速开关信号**来模拟不同电压输出的技术。就像快速眨眼——如果你半睁半闭眼睛,看起来就像在"眯着眼";PWM 也是类似的道理。 + +#### 两个重要参数 + +| 参数 | 含义 | 比喻 | +|---|---|---| +| **频率 (Frequency)** | 每秒开关多少次,单位 Hz(赫兹) | 每秒眨眼的次数 | +| **占空比 (Duty Cycle)** | 高电平时间占一个周期的比例 | 每次眨眼时,眼睛闭着的时间比例——80% 闭着 = 20% 占空比 | + +比如:频率 1000 Hz = 每秒开关 1000 次。占空比 50% = 一半时间通(高电平),一半时间断(低电平)。 + +#### 为什么步进电机用 PWM? + +步进电机是一种特殊的电机,**每收到一个脉冲就走一步**。所以: + +- PWM 的频率越高 → 脉冲来得越快 → 电机转速越快 +- PWM 的频率越低 → 脉冲来得越慢 → 电机转速越慢 + +树莓派的**硬件 PWM**(由专用硬件产生,不受 CPU 负载影响)通过 GPIO12 引脚输出脉冲,经过 StepMotor 模块驱动步进电机。 + +--- + +## 6. Python 环境搭建 + +### 6.1 检查环境 + +树莓派 OS(操作系统)自带 Python 3。打开终端(Terminal),输入以下命令检查版本: + +```bash +python3 --version +``` + +如果显示类似 `Python 3.11.x` 或更高版本,说明 Python 已安装。 + +### 6.2 创建虚拟环境(推荐) + +虚拟环境可以把本项目的 Python 依赖与你电脑上的其他项目隔离开,避免版本冲突。 + +```bash +# 进入项目文件夹 +cd pinball_exp_rp5 + +# 创建虚拟环境(在项目根目录下生成 venv 文件夹) +python3 -m venv venv + +# 激活虚拟环境 +source venv/bin/activate + +# 激活后,终端提示符前面会出现 (venv) 字样 +``` + +> 以后每次使用本项目,都先运行 `source venv/bin/activate` 来激活虚拟环境。 + +### 6.3 安装依赖 + +首先确保 `requirements.txt` 文件在项目根目录下,然后运行: + +```bash +pip install -r requirements.txt +``` + +#### 各依赖库的作用 + +| 库名 | 作用 | 安装方式 | +|---|---|---| +| **spidev** | 操作 SPI 总线的 Python 库。树莓派通过它跟 ADC 芯片通信。 | `pip install spidev` | +| **smbus2** | 操作 I2C 总线的 Python 库。树莓派通过它跟电机模块通信。 | `pip install smbus2` | +| **RPi.GPIO** | 控制 GPIO 引脚的经典库(可选)。本项目中主要用 gpiozero 代替。 | `pip install RPi.GPIO` | +| **gpiozero** | 简化版的 GPIO 控制库。Raspberry Pi OS 自带,无需额外安装。 | 系统自带 | +| **rpi_hardware_pwm** | 控制树莓派硬件 PWM 输出的库,用于步进电机的脉冲产生。 | `pip install rpi_hardware_pwm` | +| **jupyter** | 运行 `.ipynb` 笔记本文件的环境,可以在浏览器中交互式运行代码。 | `pip install jupyter` | +| **matplotlib** | 绘图库,`test.ipynb` 中用它将 ADC 采集的数据画成图表。 | `pip install matplotlib` | + +> **注意**:`rpi_hardware_pwm` 和 `matplotlib` 不在 `requirements.txt` 中,需要单独安装。 + +```bash +# 如果上面的安装命令执行完后还缺少某些库,可以手动补装: +pip install rpi_hardware_pwm matplotlib jupyter +``` + +--- + +## 7. 项目文件结构 + +``` +pinball_exp_rp5/ +│ +├── README.md # 本文件 — 项目说明(你正在读的这份文档) +├── requirements.txt # Python 依赖列表(自动安装用) +│ +├── drv_adc.py # ADC 驱动模块 — 采集电压信号(SPI 通信) +├── drv_encodermotor.py # 编码器电机驱动模块 — 控制圆柱旋转(I2C 通信) +├── drv_stepmotor.py # 步进电机驱动模块 — 控制圆柱平移(I2C + PWM) +│ +├── test.ipynb # 教学演示 Notebook(Jupyter 笔记本) +└── signal_features.json # 实验信号参数(已脱敏处理) +``` + +--- + +## 8. 如何运行 + +```bash +# 1. 激活虚拟环境(如果之前创建了虚拟环境) +source venv/bin/activate + +# 2. 启动 Jupyter Notebook +jupyter notebook + +# 3. 终端会显示一个链接(类似 http://127.0.0.1:8888/), +# 用浏览器打开这个链接 + +# 4. 在浏览器中点击 test.ipynb 文件 + +# 5. 在 Notebook 中按顺序从上到下运行每个 Cell(代码单元格): +# - 点击一个 Cell 选中它 +# - 按 Shift + Enter 运行当前 Cell 并自动跳转到下一个 +# - 也可以点工具栏上的 "Run" 按钮 +``` + +> **什么是 Jupyter Notebook?** +> Jupyter Notebook 是一种交互式的编程环境,把代码、运行结果、文字说明放在一个文件(`.ipynb`)中。每个代码块叫做一个"Cell",可以单独运行,非常适合教学和实验。 + +--- + +## 9. 三个驱动模块详解 + +### 9.1 ADC 模块(`drv_adc.py`) + +#### 控制芯片 + +**TI ADS124S08** —— 这是一款 24 位高精度模数转换器(ADC)。 + +- **24 位**意味着它可以分辨非常微小的电压变化(最小可分辨约 0.3 微伏) +- 可以把连续的模拟电压信号转换成计算机可以处理的数字信号 +- 本项目中用于读取应变片经放大后的电压信号,从而计算受力 + +#### 通信方式 + +SPI 总线,路径:`/dev/spidev0.0` + +#### 为什么要分两步给 ADC 上电? + +ADS124S08 芯片的模拟电路和数字电路需要按照特定顺序启动。如果同时上电,芯片内部状态可能不稳定。所以驱动程序先启动第一部分电源(Stage 1),等待 100ms 让电源稳定后,再启动第二部分电源(Stage 2)。 + +#### 典型调用流程 + +```python +from drv_adc import ADS124S08 + +# 1. 创建 ADC 对象(自动完成上电和初始化) +adc = ADS124S08() + +# 2. 配置可编程增益放大器(PGA) +# pga_en=1: 启用放大 gain=5: 放大 32 倍 +adc.set_pga(pga_en=1, gain=5) + +# 3. 配置采样率 +# dr=8: 200 SPS(每秒采样 200 次) mode=1: 单次转换模式 +adc.set_datarate(dr=8, mode=1) + +# 4. 选择要测量的输入通道(单端模式,以 AINCOM 为公共端) +# 这里选择通道 0(AIN0 引脚) +adc.set_input_mux(pos_channel=0) + +# 5. 读取多个通道的电压原始值 +values = adc.request_channels([0, 1, 2]) + +# 6. 清理(关闭 SPI 总线,关掉 GPIO 电源) +adc.close() +``` + +#### 几个重要的配置 + +**PGA 增益倍数对照表:** + +| gain 编码 | 实际放大倍数 | 适用信号 | +|---|---|---| +| 0 | ×1 | 强信号(>1V) | +| 1 | ×2 | 中等信号 | +| 2 | ×4 | 中等信号 | +| 3 | ×8 | 较弱信号 | +| 4 | ×16 | 弱信号 | +| 5 | ×32 | 微弱信号(如应变片) | +| 6 | ×64 | 极微弱信号 | +| 7 | ×128 | 最微弱信号 | + +**数据速率对照表:** + +| dr 编码 | 实际速率(SPS) | 每次采样等待时间 | +|---|---|---| +| 0 | 2.5 | 400 ms | +| 4 | 20 | 50 ms | +| 8 | 200 | 5 ms | +| 13 | 4000 | 0.25 ms | + +> **SPS** = Samples Per Second(每秒采样次数)。数值越大,采样越快,但噪声也越大。 + +--- + +### 9.2 编码器电机模块(`drv_encodermotor.py`) + +#### 控制硬件 + +**M5Stack Module 4EncoderMotor** —— 一个可以同时控制 4 个空心杯电机的模块。每个电机都带有编码器(可以测量电机转了多少圈)。 + +#### 通信方式 + +**I2C** 总线,模块地址:**0x24** + +#### 什么是编码器? + +编码器是安装在电机轴上的传感器。电机每转一个角度,编码器就产生一个脉冲。通过统计脉冲数量,计算机就知道电机转了多少圈、转到了哪个位置。 + +#### 三种控制模式 + +| 模式 | 名称 | 说明 | +|---|---|---| +| `NORMAL_MODE` (0x00) | 普通模式 | 直接设置 PWM 占空比控制转速,没有闭环控制 | +| `POSITION_MODE` (0x01) | 位置模式 | 使用 PID 闭环控制,让电机转到指定位置并停住 | +| `SPEED_MODE` (0x02) | 速度模式 | 使用 PID 闭环控制,让电机保持指定的转速 | + +#### 什么是 PID 控制? + +PID 是比例(P)-积分(I)-微分(D)控制的简称,是一种自动调节技术。打个比方: + +你想让水龙头流出的水稳定在每分钟 1 升: + +- **P(比例)**:看当前流量跟目标差多少,差得多就多拧一点,差得少就少拧一点 +- **I(积分)**:如果长时间有微小偏差(比如一直是 0.98 升),就慢慢增加拧开的幅度 +- **D(微分)**:如果流量突然变化(有人碰了水管),就快速反向调整一下 + +三个参数配合,就能让电机精确地达到并保持目标速度或位置。 + +#### 典型调用流程 + +```python +from drv_encodermotor import EncoderMotorDriver, SPEED_MODE + +# 使用 with 语句(自动打开和关闭 I2C 连接) +with EncoderMotorDriver(bus=1) as driver: + + # 1. 设置电机 0 为速度模式 + driver.set_mode(0, SPEED_MODE) + + # 2. 设置速度模式的 PID 参数 + driver.set_speed_pid(0, kp=1, ki=100, kd=1) + + # 3. 设置线速度(单位:米/秒) + # 电机将以 0.01 米/秒的速度旋转 + driver.set_linear_speed_m_s(0, 0.01) +``` + +> `with` 语句是 Python 的一种语法,确保无论代码是否出错,I2C 连接都会被正确关闭。等价于 `try: ... finally: driver.close()`。 + +--- + +### 9.3 步进电机模块(`drv_stepmotor.py`) + +#### 控制硬件 + +**M5Stack StepMotor Driver** —— 一个可以驱动最多 3 个步进电机的模块。 + +#### 通信方式 + +- **I2C**(地址 0x27):用于模块配置(使能、复位等) +- **硬件 PWM**(GPIO12):用于产生步进脉冲(控制电机速度) + +#### 什么是步进电机? + +步进电机是一种特殊的电机,**每收到一个电脉冲就转动一个固定的角度**(称为"步距角")。本项目中电机的步距角是 1.8°(每圈 200 步),加上驱动器的微步进和减速器后,每圈需要 **89,600 个脉冲**。 + +#### 三个电机的关系 + +- 三个步进电机**共享同一路 PWM**(即同时以相同的频率步进) +- 但每个电机可以**独立控制方向**(通过独立的 GPIO 引脚) +- 这样三个圆柱可以同时移动,但方向可以不同 + +#### S 曲线加减速 + +电机突然启动或停止会产生机械冲击。本项目使用 **S 曲线加减速**来让电机平滑地加速和减速。名字来源于速度随时间变化的形状像字母 "S"。 + +具体做法是用**余弦函数**来计算每个时刻的速度: + +- 启动时:速度从 0 慢慢增加(就像汽车起步) +- 中间阶段:加速变快 +- 接近目标速度时:加速又变慢,最终平稳到达目标速度 + +减速时反过来,整个过程非常平滑,减少了机械冲击和电机失步的风险。 + +#### 典型调用流程 + +```python +from drv_stepmotor import StepMotorDriver + +# 1. 创建步进电机驱动对象 +drv = StepMotorDriver() + +# 2. 使能电机(允许接收脉冲) +drv.enable_motor() + +# 3. 设置方向(True = 正向) +drv.set_dir(True) + +# 4. 启动电机并以 S 曲线加速到 0.01 米/秒 +drv.start(0.01) + +# 5. 在运行中改变速度 +# drv.change_speed(0.02) + +# 6. 以 S 曲线减速停止 +drv.stop() + +# 7. 清理资源 +drv.close() + +# 也支持 with 语句 +# with StepMotorDriver() as drv: +# drv.enable_motor() +# drv.set_dir(True) +# drv.start(0.01) +# time.sleep(2) +# drv.stop() +``` + +--- + +## 10. `signal_features.json` 说明 + +这个文件存储了从 **LBM(格子玻尔兹曼方法)** 计算机仿真中提取的信号特征。LBM 是一种流体力学仿真方法,用于模拟水流绕过圆柱时的受力情况。 + +### 文件内容格式 + +```json +{ + "signals": [ + { + "name": "action1", // 第 1 个圆柱的信号 + "mean": 0.00166, // 信号的直流分量(平均值) + "components": [ // 正弦波分量列表 + { + "frequency": 0.135, // 频率 (Hz) + "amplitude": 0.00137, // 幅值 + "phase": 0.187 // 相位 (弧度) + } + ] + } + ] +} +``` + +每个 action(对应一个圆柱)包含: +- **mean(均值)**:信号的直流偏置 +- **多个正弦波分量**:每个分量有频率、幅值、相位 + +这些参数用于在主控制循环中合成周期运动信号,让圆柱按照仿真得出的最优方式运动。**数据已做脱敏处理**(数值做了随机偏移),仅供学习参考。 + +--- + +## 11. 常见问题 FAQ + +#### Q: SPI 设备找不到 + +```bash +ls -l /dev/spidev* +``` +如果显示 `No such file or directory`,说明 SPI 接口未启用。 + +**解决方法**: +```bash +sudo raspi-config +# 进入 Interface Options → SPI → Enable +# 重启树莓派 +``` + +#### Q: I2C 设备找不到 + +```bash +i2cdetect -y 1 +``` +如果命令不存在或没有显示设备地址,说明 I2C 接口未启用。 + +**解决方法**: +```bash +sudo raspi-config +# 进入 Interface Options → I2C → Enable +# 重启树莓派 +``` + +扫描到的设备地址应该显示为十六进制编号(如 `24` 和 `27`)。 + +#### Q: 电机不转 + +请按顺序检查: + +1. **电源**:电机模块是否有外部电源供电?树莓派的 5V 引脚通常不足以驱动电机 +2. **使能信号**:是否调用了 `enable_motor()`(步进电机)或设置了正确的模式(编码器电机) +3. **接线**:I2C 连线是否正确(SDA → GPIO2,SCL → GPIO3) +4. **地址**:I2C 地址是否匹配(编码器电机 0x24,步进电机 0x27) + +#### Q: ADC 读数为 0 + +1. 检查 PGA 配置是否正确(增益是否设得太低,信号太弱读不出来) +2. 检查输入通道选择是否正确(是不是选对了 AIN 引脚) +3. 检查 SPI 接线 +4. 用 `check_status()` 方法查看 ADC 状态寄存器 + +#### Q: ImportError: No module named xxx + +运行以下命令安装缺失的库: + +```bash +pip install xxx +``` + +常用的库安装命令: + +```bash +pip install spidev smbus2 rpi_hardware_pwm matplotlib jupyter +``` + +#### Q: Permission denied(权限不足) + +通常是因为当前用户没有访问 SPI 或 I2C 设备的权限。`pi` 用户默认有权限。如果使用其他用户,可以将用户加入相关组: + +```bash +sudo usermod -a -G spi,i2c,gpio 用户名 +# 然后注销并重新登录 +``` + +--- + +## 12. 参考资料 + +- **ADS124S08 数据手册(TI 官方网站)** + https://www.ti.com/product/ADS124S08 + +- **M5Stack 4EncoderMotor 模块文档** + https://docs.m5stack.com/en/module/Module_4EncoderMotor_V1.1 + +- **M5Stack StepMotor Driver 模块文档** + https://docs.m5stack.com/en/module/Stepmotor%20Driver%20Module13.2%20v1.1 + +- **树莓派官方文档** + https://www.raspberrypi.com/documentation/ + +- **树莓派 GPIO 引脚说明** + https://www.raspberrypi.com/documentation/computers/raspberry-pi.html + +- **spidev Python 库** + https://pypi.org/project/spidev/ + +- **smbus2 Python 库** + https://pypi.org/project/smbus2/ + +- **gpiozero Python 库** + https://gpiozero.readthedocs.io/ diff --git a/drv_adc.py b/drv_adc.py new file mode 100644 index 0000000..7d547af --- /dev/null +++ b/drv_adc.py @@ -0,0 +1,1006 @@ +""" +ADS124S08 ADC 驱动模块 — 树莓派 5 用户空间 SPI 驱动 + +本模块实现了 ADS124S08 模数转换器(ADC)的核心功能: +- 寄存器读写操作 +- 通过 GPIO 控制芯片上电时序的设备初始化 +- PGA(可编程增益放大器)配置 +- 输入多路复用器选择(单端或差分输入) +- 数据速率和转换模式设置 +- DRDY(数据就绪)引脚处理 +- 单次/连续转换模式 + +SPI 通信简介: +SPI(Serial Peripheral Interface,串行外设接口)是一种同步串行通信协议, +使用四根信号线:SCLK(时钟线)、MOSI(主设备输出/从设备输入)、 +MISO(主设备输入/从设备输出)、CS(片选线,低电平有效)。 +SPI 是全双工通信,主设备(树莓派)发送时钟信号的同时,主设备和从设备(ADC) +可以同时发送和接收数据。 + +引脚接线表(树莓派 5): +- SPI 总线:/dev/spidev0.0(bus=0, device=0) +- DRDY 引脚(数据就绪指示,低电平表示有数据可读):GPIO25 +- 上电阶段 1 控制引脚(先拉高,给 ADC 供电的第一部分):GPIO22、GPIO17 +- 上电阶段 2 控制引脚(100ms 后拉高,给 ADC 供电的第二部分):GPIO23、GPIO27 +- 保持低电平引脚(初始化期间强制保持低电平):GPIO24、GPIO18 + +为什么要分两步上电: +ADS124S08 芯片的模拟电源和数字电源需要按顺序启动,如果同时上电可能 +导致芯片内部状态不确定。先启动第一部分电源(Stage 1),等待 100ms 让电源稳定后, +再启动第二部分电源(Stage 2),可以确保芯片可靠启动。 + +上电时序流程: +1. 将阶段 1 的 GPIO 引脚拉高(输出高电平),开启第一阶段供电,等待 100ms +2. 将阶段 2 的 GPIO 引脚拉高,开启第二阶段供电 +3. 发送复位命令,让 ADC 芯片重新启动 +4. 配置寄存器(PGA 增益、输入多路复用器、数据速率等) + +ADC 芯片数据手册参考链接: +https://www.ti.com.cn/product/cn/ADS124S08?keyMatch=ADS124S08&tisearch=universal_search + +""" + + +# 从 __future__ 模块导入 annotations 特性。 +# 这允许在方法的类型注解中使用字符串形式(如 -> "SomeClass"),而不是直接引用类名, +# 从而避免在类定义内部引用自身或其他尚未完全定义的类时出现 NameError 错误。 +from __future__ import annotations + +# 从 Python 标准库 signal 模块导入 pause 函数。 +# pause() 会让程序进入等待状态,直到接收到信号(如 SIGINT,即按 Ctrl+C)才会继续执行。 +# 在这里用于在主程序中保持程序持续运行,不退出。 +from signal import pause + +# 导入 spidev 库。spidev 是树莓派上用户空间操作 SPI 总线的 Python 库, +# 它封装了 Linux SPI 设备文件(如 /dev/spidev0.0)的 ioctl 系统调用, +# 让我们可以方便地通过 SPI 协议与外部设备(如 ADS124S08 ADC)进行数据收发。 +import spidev + +# 从 gpiozero 库导入 DigitalOutputDevice(数字输出设备)和 Button(按钮/输入设备)。 +# DigitalOutputDevice 用于控制 GPIO 引脚输出高电平或低电平,比如控制 ADC 芯片的电源开关。 +# Button 用于检测 GPIO 引脚的电平变化(从高到低或从低到高), +# 在这里用于检测 ADC 的 DRDY(数据就绪)引脚是否变低。 +from gpiozero import DigitalOutputDevice, Button + +# 导入整个 gpiozero 库(上面导入的是其中的特定类,这里导入整个库以备需要其他功能)。 +import gpiozero + +# 导入 Python 标准库中的 time 模块,用于执行延时(sleep)、获取当前时间等操作。 +# 在本驱动中,time.sleep() 用于在 GPIO 上电步骤之间等待电源稳定, +# 以及在等待 ADC 完成转换时等待足够的时间。 +import time + +# ============================================================================= +# ADS124S08 命令码定义 +# 下面这些是发给 ADC 芯片的命令字节,每个命令都是一个 1 字节(8 位)的值。 +# 使用时通过 SPI 的 MOSI 线发送给 ADC,ADC 收到后执行对应操作。 +# ============================================================================= +# 空操作(No OPeration),发给 ADC 后它什么也不做,通常用于占位或填充 SPI 时钟周期 +ADS124S08_CMD_NOP = 0x00 +# 唤醒命令,让处于休眠(断电)模式的 ADC 芯片恢复到正常工作状态 +ADS124S08_CMD_WAKEUP = 0x02 +# 断电命令(PoWeR DoWN),让 ADC 芯片进入低功耗休眠模式,以节省电能 +ADS124S08_CMD_PWRDWN = 0x04 +# 复位命令(RESET),让 ADC 芯片重新启动,所有寄存器恢复到默认值 +ADS124S08_CMD_RESET = 0x06 +# 启动转换命令(START),告诉 ADC 开始进行模数转换 +ADS124S08_CMD_START = 0x08 +# 停止转换命令(STOP),告诉 ADC 停止当前的模数转换 +ADS124S08_CMD_STOP = 0x0a +# 系统失调校准命令(SYstem OCal Calibration),对整个系统进行偏移量校准 +ADS124S08_CMD_SYOCAL = 0x16 +# 系统增益校准命令(SYstem Gain Calibration),对整个系统进行增益校准 +ADS124S08_CMD_SYGCAL = 0x17 +# 自失调校准命令(SelF OCal Calibration),只对 ADC 自身进行偏移量校准 +ADS124S08_CMD_SFOCAL = 0x19 +# 读取数据命令(Read DATA),告诉 ADC 把最新的转换结果发送回来 +ADS124S08_CMD_RDATA = 0x12 +# 读寄存器命令(Read REGister),后面需要跟寄存器地址和要读取的寄存器数量 +ADS124S08_CMD_RREG = 0x20 +# 写寄存器命令(Write REGister),后面需要跟寄存器地址和要写入的数据 +# 注意:RREG 和 WREG 的最高位不同(0x20 vs 0x40),这是 ADC 用来区分读写操作的标志 +ADS124S08_CMD_WREG = 0x40 + +# ============================================================================= +# ADS124S08 寄存器地址定义 +# 寄存器是 ADC 芯片内部的一些存储单元,每个寄存器有 1 字节(8 位)宽度。 +# 我们可以通过 SPI 发送 RREG(读寄存器)或 WREG(写寄存器)命令来读取或修改它们。 +# 寄存器控制着 ADC 的各种工作参数,比如选择哪个输入通道、设置放大倍数、选择数据速率等。 +# ============================================================================= +# 芯片 ID 寄存器(出厂固化),只读,存放芯片型号和版本号,可以用来验证 SPI 通信是否正常 +ADS124S08_ID_REG = 0x00 +# 状态寄存器(STATUS),存放 ADC 的工作状态标志,如是否发生过上电复位、是否有数据就绪等 +ADS124S08_STATUS = 0x01 +# 输入多路复用器寄存器(INPUT MUX),控制选择哪个模拟输入引脚接到 ADC 内部 +ADS124S08_INPUT_MUX = 0x02 +# PGA(可编程增益放大器)寄存器,控制是否启用前置放大以及放大多少倍(1~128 倍) +ADS124S08_PGA = 0x03 +# 数据速率寄存器(DATA RATE),控制 ADC 每秒采样多少次(2.5 SPS ~ 4000 SPS) +ADS124S08_DATA_RATE = 0x04 +# 参考电压寄存器(REF),配置参考电压的来源和缓冲器 +ADS124S08_REF = 0x05 +# 激励电流源幅度寄存器(IDAC MAGnitude),设置恒流源电流大小 +ADS124S08_IDACMAG = 0x06 +# 激励电流源输出引脚选择寄存器(IDAC MUX),选择恒流源接到哪个 AIN 引脚 +ADS124S08_IDACMUX = 0x07 +# 偏置电压寄存器(VBIAS),配置是否在输入引脚上叠加一个偏置电压 +ADS124S08_VBIAS = 0x08 +# 系统控制寄存器(SYS),控制 CRC 校验等系统级功能 +ADS124S08_SYS = 0x09 +# 失调校准寄存器 0(OFfset CALibration 0),用于细调 ADC 的零偏(低 8 位) +ADS124S08_OFCAL0 = 0x0a +# 失调校准寄存器 1(OFfset CALibration 1),用于细调 ADC 的零偏(中间 8 位) +ADS124S08_OFCAL1 = 0x0b +# 失调校准寄存器 2(OFfset CALibration 2),用于细调 ADC 的零偏(高 8 位) +ADS124S08_OFCAL2 = 0x0c +# 满量程校准寄存器 0(Full SCale CALibration 0),用于校准 ADC 的增益误差(低 8 位) +ADS124S08_FSCAL0 = 0x0d +# 满量程校准寄存器 1(Full SCale CALibration 1),用于校准 ADC 的增益误差(中间 8 位) +ADS124S08_FSCAL1 = 0x0e +# 满量程校准寄存器 2(Full SCale CALibration 2),用于校准 ADC 的增益误差(高 8 位) +ADS124S08_FSCAL2 = 0x0f +# GPIO 数据寄存器(GPIO DATa),通过 SPI 读写 ADC 芯片上的通用输入输出引脚电平 +ADS124S08_GPIODAT = 0x10 +# GPIO 配置寄存器(GPIO CONfiguration),配置 ADC 芯片上 GPIO 引脚的方向(输入/输出) +ADS124S08_GPIOCON = 0x11 + +# ============================================================================= +# ADS124S08 模拟输入通道号定义 +# ADS124S08 有 12 个模拟输入引脚(AIN0 ~ AIN11),可以通过 INPUT_MUX 寄存器 +# 选择连接到 ADC 内部的哪个通道。此外还有一个 AINCOM(公共端)引脚, +# 用于单端测量时作为参考地(负端)。 +# ============================================================================= +# 模拟输入引脚 0,地址编码为 0x00(二进制 0000) +ADS124S08_AIN0 = 0x00 +# 模拟输入引脚 1,地址编码为 0x01(二进制 0001) +ADS124S08_AIN1 = 0x01 +# 模拟输入引脚 2,地址编码为 0x02(二进制 0010) +ADS124S08_AIN2 = 0x02 +# 模拟输入引脚 3,地址编码为 0x03(二进制 0011) +ADS124S08_AIN3 = 0x03 +# 模拟输入引脚 4,地址编码为 0x04(二进制 0100) +ADS124S08_AIN4 = 0x04 +# 模拟输入引脚 5,地址编码为 0x05(二进制 0101) +ADS124S08_AIN5 = 0x05 +# 模拟输入引脚 6,地址编码为 0x06(二进制 0110) +ADS124S08_AIN6 = 0x06 +# 模拟输入引脚 7,地址编码为 0x07(二进制 0111) +ADS124S08_AIN7 = 0x07 +# 模拟输入引脚 8,地址编码为 0x08(二进制 1000) +ADS124S08_AIN8 = 0x08 +# 模拟输入引脚 9,地址编码为 0x09(二进制 1001) +ADS124S08_AIN9 = 0x09 +# 模拟输入引脚 10,地址编码为 0x0a(二进制 1010) +ADS124S08_AIN10 = 0x0a +# 模拟输入引脚 11,地址编码为 0x0b(二进制 1011) +ADS124S08_AIN11 = 0x0b +# 公共端引脚(AINCOM,地址编码 0x0c),在单端测量时作为负输入端(参考地), +# 即测量 AINx 相对于 AINCOM 的电压。在差分配置下,AINCOM 也可以作为一个普通的输入端。 +ADS124S08_AINCOM = 0x0c + + +# ============================================================================= +# 定义 ADS124S08 类,封装了 ADC 芯片的所有操作 +# ============================================================================= + +# 定义一个名为 ADS124S08 的类(class),类相当于一个模板,通过它可以创建 ADC 对象实例。 +# 每个实例有自己的 SPI 连接、GPIO 引脚设置等,可以独立控制一块 ADC 芯片。 +class ADS124S08: + """ADS124S08 ADC 驱动类(用户空间驱动) + + 这个类封装了对 ADS124S08 模数转换器的所有操作,包括: + - SPI 总线的打开和关闭 + - GPIO 电源控制(分步上电) + - 寄存器读写 + - 配置增益、输入通道、数据速率等 + - 读取转换结果 + + 基本用法示例: + adc = ADS124S08(bus=0, device=0) # 创建 ADC 对象,连接到 SPI0.0 + adc.reset() # 复位 ADC 芯片 + val = adc.read_channel(0) # 读取通道 0 的电压值 + + 参数说明: + - bus/device:SPI 总线和设备号,例如 bus=0, device=0 对应 /dev/spidev0.0 + - max_speed_hz:SPI 通信时钟频率,单位赫兹(Hz),影响通信速度 + - mode:SPI 工作模式(0~3),由时钟极性(CPOL)和时钟相位(CPHA)组合决定 + - reset_pin:可选的复位引脚(BCM GPIO 编号),如果提供则通过 GPIO 硬件复位(需要 RPi.GPIO 库) + """ + + def __init__(self): + """初始化 ADC 对象,完成以下步骤: + 1. 设置 SPI 总线和设备号(默认 bus=0, device=0,对应 /dev/spidev0.0) + 2. 打开 SPI 设备文件,建立与 ADC 芯片的通信连接 + 3. 配置 SPI 通信参数(时钟频率 1MHz,模式 1) + 4. 设置 GPIO 引脚编号(DRDY、上电控制、保持低电平) + 5. 初始化 GPIO 引脚的电平状态 + 6. 分两步给 ADC 上电(Stage 1 -> 100ms 等待 -> Stage 2) + 7. 发送复位命令,让 ADC 恢复到默认状态 + 8. 读取所有寄存器值,验证 SPI 通信是否正常 + 9. 清除上电复位标志 + 10. 配置参考电压源 + + 注意:构造函数不需要额外参数,所有配置使用默认值。 + 如果要修改配置,可以在创建对象后调用对应的 set_xxx() 方法。 + """ + + # ----------------------------------------------------------------------- + # SPI 总线配置 + # ----------------------------------------------------------------------- + # 设置 SPI 总线编号为 0(树莓派有 2 个 SPI 总线:SPI0 和 SPI1) + self.bus = 0 + # 设置 SPI 设备编号为 0(每个 SPI 总线上可以有多个设备,通过片选引脚 CS 区分) + self.device = 0 + # 创建一个 SpiDev 对象,这是 spidev 库提供的 SPI 通信接口 + self.spi = spidev.SpiDev() + # 尝试打开 SPI 设备文件(在 Linux 中对应 /dev/spidev0.0 这个设备文件) + try: + self.spi.open(self.bus, self.device) + # 如果 SPI 设备文件不存在(例如内核没加载 SPI 驱动,或设备号不对),会抛出 FileNotFoundError + except FileNotFoundError as e: + # 抛出更清晰的异常信息,告诉用户是哪个 SPI 设备打开失败了 + raise Exception(f"SPI device /dev/spidev{self.bus}.{self.device} not found: {e}") + + # 设置 SPI 通信时钟频率为 1 MHz(1,000,000 Hz), + # 这是主设备(树莓派)和从设备(ADC)之间数据传输的速率。 + # 频率越高通信越快,但信号质量可能下降。1 MHz 是 ADC 芯片支持的常用频率。 + self.spi.max_speed_hz = 1_000_000 # 1 MHz 默认值 + # 设置 SPI 模式为 0b01(即模式 1), + # SPI 有 4 种模式(0~3),由时钟极性(CPOL)和时钟相位(CPHA)决定: + # 模式 1 = CPOL=0, CPHA=1:空闲时时钟为低电平,在下降沿采样数据 + self.spi.mode = 0b01 # 默认使用 SPI 模式 1 + # 设置 CRC 校验默认关闭(由 set_crc() 方法控制) + self.en_crc = False # 默认禁用 CRC 校验 + + # ----------------------------------------------------------------------- + # GPIO 引脚配置 + # ----------------------------------------------------------------------- + # DRDY 引脚(Data Ready,数据就绪),连接 ADC 的 DRDY 引脚到树莓派的 GPIO25。 + # 当 ADC 完成一次转换且有新数据可读时,DRDY 引脚会从高电平变为低电平。 + self.drdy_pin = 25 + + # 上电阶段 1 的 GPIO 引脚列表(GPIO22 和 GPIO17), + # 这两个引脚在初始化时先被拉高(输出高电平),开启 ADC 的第一部分电源 + self.power_stage1 = [22, 17] + # 上电阶段 2 的 GPIO 引脚列表(GPIO24 和 GPIO27), + # 这两个引脚在阶段 1 完成 100ms 后被拉高,开启 ADC 的第二部分电源 + self.power_stage2 = [24, 27] + # 保持低电平的 GPIO 引脚列表(GPIO23 和 GPIO18), + # 这些引脚在整个初始化期间被强制保持为低电平(输出低电平) + self.gpio_hold_low = [23, 18] + + # 创建一个 Button 对象来监控 DRDY 引脚。 + # gpiozero 的 Button 类默认检测引脚从高电平变为低电平(下降沿触发), + # 这正好对应 DRDY 引脚"数据就绪时变低"的行为。 + # 如果 drdy_pin 为 None,则 self._drdy_pin 也设为 None(不使用 DRDY 引脚检测)。 + self._drdy_pin = Button(self.drdy_pin) if self.drdy_pin is not None else None + + # ----------------------------------------------------------------------- + # GPIO 初始化 — 将所有控制引脚设为初始状态 + # ----------------------------------------------------------------------- + # 创建一个空字典,用于存储所有 GPIO 引脚对应的 DigitalOutputDevice 对象, + # 方便后续通过引脚编号快速找到对应的设备对象来控制其电平。 + self._gpio_devices = {} + # 遍历 power_stage1、power_stage2 和 gpio_hold_low 中的所有引脚编号, + # 用 set() 去重,确保每个引脚只创建一次设备对象。 + for p in set(self.power_stage1 + self.power_stage2 + self.gpio_hold_low): + # 尝试为每个引脚创建一个 DigitalOutputDevice 对象,初始电平设为 False(低电平) + try: + self._gpio_devices[p] = DigitalOutputDevice(p, initial_value=False) + # 如果创建失败(例如引脚被占用或 GPIO 权限不足),则跳过该引脚 + except Exception: + pass + + # 确保"保持低电平"的引脚确实处于低电平状态(再次确认) + for p in self.gpio_hold_low: + dev = self._gpio_devices.get(p) + if dev: + dev.off() + + # ----------------------------------------------------------------------- + # 上电阶段 1:先给 ADC 提供第一部分电源 + # 为什么要分两步:ADS124S08 的模拟和数字部分需要按顺序供电, + # 如果同时上电可能导致内部电路状态不确定。先让第一部分稳定,再启动第二部分。 + # ----------------------------------------------------------------------- + for p in self.power_stage1: + dev = self._gpio_devices.get(p) + if dev: + # 调用 on() 方法将引脚设为高电平(输出 3.3V),开启第一路供电 + dev.on() + # 等待 100 毫秒(0.1 秒),让第一部分电源稳定下来,消除电源波动 + time.sleep(0.100) + + # ----------------------------------------------------------------------- + # 上电阶段 2:给 ADC 提供第二部分电源 + # ----------------------------------------------------------------------- + for p in self.power_stage2: + dev = self._gpio_devices.get(p) + if dev: + # 将引脚设为高电平,开启第二路供电 + dev.on() + # 再次等待 100 毫秒,等待 ADC 芯片内部电源管理电路完成初始化 + time.sleep(0.100) + # 发送复位命令,让 ADC 芯片重新启动,所有寄存器恢复到数据手册规定的默认值 + self.reset() + # 等待 100 毫秒,让复位完成(复位后芯片内部需要时间重新初始化) + time.sleep(0.100) + # 通过 SPI 发送 19 个字节来读取所有寄存器(地址 0x00 ~ 0x11,共 18 个): + # - 第 1 个字节 = RREG 命令(0x20 | 0x01),表示从地址 0x01 开始读(实际第一个字节是地址 0x00) + # - 第 2 个字节 = 0x10(十进制 16),表示要连续读取 17 个寄存器(寄存器数量 = 该值 + 1) + # - 第 3~19 个字节 = 填充 0x00,用于给 SPI 提供时钟信号以读取返回数据 + rx = self.spi.xfer2([ADS124S08_CMD_RREG | (0x01 & 0xFF), 0x10] + [0x00]*17) + # 复位后各寄存器的默认值列表(从数据手册获得),用于验证 SPI 通信是否正确 + default_regs = [0x80, 0x01, 0x00, 0x14, 0x10, 0x00, 0xff, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00] + # 如果收到的数据长度是 19 字节,说明通信正常(发送 3 字节收到 3 字节,SPI 是全双工的) + if len(rx) == 19: + # 将读取到的寄存器值(从 rx[2] 开始,前两个字节是命令的回显)与默认值逐一对比 + for i, (actual, expected) in enumerate(zip(rx[2:], default_regs)): + # 如果某个寄存器的值与期望值不符,说明 SPI 通信有问题或芯片工作异常 + if actual != expected: + # 抛出异常,报告是哪个寄存器不匹配 + raise Exception(f"Register {i} mismatch: expected 0x{expected:02x}, got 0x{actual:02x}") + # 清除 STATUS 寄存器中的上电复位(Power-On Reset)标志位, + # 告诉芯片"我们已经知道发生过上电复位了",这样下次就可以通过检查这个标志来判断是否掉电重启过 + self.clear_por() + # 配置参考电压源: + # - en_monitor=0:禁用参考电压监控 + # - en_bufp=1:使能正参考电压缓冲器(提高带负载能力) + # - en_bufn=1:使能负参考电压缓冲器 + # - refsel=2:选择内部参考电压(2.5V) + # - refcon=1:内部参考电压开启(但在待机模式下可以关闭以省电) + self.set_ref(en_monitor=0, en_bufp=1, en_bufn=1, refsel=2, refcon=1) + + def close(self) -> None: + """关闭并清理所有资源,释放 SPI 总线和 GPIO 引脚。 + + 在程序结束或不再需要使用 ADC 时调用此方法,确保: + 1. 关闭 SPI 设备文件,释放 SPI 总线资源 + 2. 将所有 GPIO 控制引脚设置为低电平 + 3. 关闭并释放所有 GPIO 设备对象 + 4. 关闭 DRDY 引脚检测对象 + """ + # 使用 try...finally 结构,无论 SPI 关闭是否成功,都会执行后续的清理操作 + try: + # 检查 SPI 对象是否存在(防止重复关闭) + if self.spi: + # 关闭 SPI 设备文件 /dev/spidev0.0,释放总线资源 + self.spi.close() + finally: + # 将 SPI 对象设为 None,标记为已关闭,防止后续误用 + self.spi = None # type: ignore + # 清理所有 gpiozero 创建的 GPIO 设备对象 + if hasattr(self, '_gpio_devices'): + # 遍历字典中所有 GPIO 设备 + for dev in self._gpio_devices.values(): + try: + # 先将引脚设为低电平(关断电源) + dev.off() + # 关闭并释放 GPIO 设备对象 + dev.close() + except Exception: + # 如果关闭失败(例如设备已被其他程序占用),静默跳过 + pass + # 检查 DRDY 引脚对象是否存在 + if self._drdy_pin is not None: + try: + # 关闭 DRDY 引脚检测对象 + self._drdy_pin.close() + except Exception: + pass + + def reset(self) -> None: + """复位 ADC 芯片。 + + 通过 SPI 向 ADC 发送复位命令(RESET = 0x06),让芯片重新启动。 + 复位后所有寄存器恢复为数据手册中的默认值,等效于给芯片断电再重新上电的效果。 + """ + # 通过 SPI 发送复位命令字节 0x06,让 ADC 芯片执行硬件复位 + self.write_cmd(ADS124S08_CMD_RESET) + # 根据数据手册要求,复位后需要等待一段时间让芯片内部完成初始化。 + # 这里等待 10 毫秒(0.01 秒),确保复位操作完成。 + time.sleep(0.01) + + def write_cmd(self, cmd: int) -> None: + """通过 SPI 向 ADC 发送单字节命令。 + + 这是最底层的命令发送函数,只发送一个字节。 + 常用于发送 NOP(空操作)、RESET(复位)、START(启动转换)、STOP(停止转换)等命令。 + + 参数: + cmd: 要发送的命令字节(0~255 的整数),例如 ADS124S08_CMD_RESET = 0x06 + """ + # 通过 SPI 的 xfer2 方法发送 1 个字节。 + # xfer2 是 spidev 库的全双工传输函数,会同时从 MISO 线接收相同数量的字节。 + # cmd & 0xFF 确保只取低 8 位,防止数值超过一个字节。 + self.spi.xfer2([cmd & 0xFF]) + + def write_reg(self, reg: int, data: int) -> None: + """向 ADC 的指定寄存器写入数据(1 个寄存器)。 + + 通过 SPI 发送 3 个字节: + - 第 1 字节:WREG 命令码(0x40),与寄存器地址按位或(|)组合 + - 第 2 字节:0x00,表示只写 1 个寄存器(寄存器数量 = 该值) + - 第 3 字节:要写入寄存器的数据 + + 参数: + reg: 寄存器地址(0x00 ~ 0x11),例如 ADS124S08_PGA = 0x03 + data: 要写入的数据(0~255),按寄存器的位定义填充 + """ + # 通过 SPI 发送 3 个字节: + # [0x40 | reg] 把 WREG 命令码和寄存器地址组合成一个字节 + # [0x00] 表示要写的寄存器数量减 1(0 表示写 1 个) + # [data & 0xFF] 是要写入寄存器的数据,& 0xFF 确保不超过一个字节 + self.spi.xfer2([ADS124S08_CMD_WREG | (reg & 0xFF), 0x00, data & 0xFF]) + + def read_reg(self, reg: int) -> int: + """读取 ADC 指定寄存器的值(1 个寄存器)。 + + 通过 SPI 发送 3 个字节,同时收到 3 个字节: + - 发送的第 1 字节:RREG 命令码(0x20),与寄存器地址按位或组合 + - 发送的第 2 字节:0x00,表示只读 1 个寄存器 + - 发送的第 3 字节:0x00(填充字节,用于产生 SPI 时钟信号让 ADC 把数据发送回来) + - 接收的第 3 字节:寄存器的当前值 + + 参数: + reg: 寄存器地址(0x00 ~ 0x11) + + 返回: + 寄存器的当前值(0~255 的整数) + """ + # RREG 命令格式:[命令码 | 寄存器地址, 寄存器数量-1, 填充字节] + # 其中 0x20 是 RREG 命令的基地址,0x00 表示读 1 个寄存器 + rx = self.spi.xfer2([ADS124S08_CMD_RREG | (reg & 0xFF), 0x00, 0x00]) + # 检查接收到的数据长度是否足够(至少 3 字节),防止 SPI 通信异常 + if len(rx) < 3: + raise Exception("Short SPI read from device") + # 返回第 3 个字节(索引 2),即寄存器的值,& 0xFF 确保只取低 8 位 + return rx[2] & 0xFF + + def set_crc(self, en: bool) -> None: + """启用或禁用 ADC 通信的 CRC 校验功能。 + + CRC(循环冗余校验)是一种数据校验方法,用于检测 SPI 通信过程中数据是否被干扰。 + 启用后,ADC 在发送数据时会附加一个 CRC 校验字节,接收方根据数据重新计算 CRC, + 如果与收到的 CRC 不一致,说明通信发生了错误。 + + 参数: + en: True 表示启用 CRC 校验,False 表示禁用 + """ + # 在 Python 对象中记录 CRC 是否启用的状态 + self.en_crc = en + # 先读取 SYS(系统控制)寄存器的当前值 + val = self.read_reg(ADS124S08_SYS) + if en: + # 如果要启用 CRC:将 SYS 寄存器的 bit 1(二进制 00000010)置 1 + # 按位或运算(|=):要置 1 的位写 1,其他位保持不变 + val |= 0b00000010 # 设置 CRC 使能位 + else: + # 如果要禁用 CRC:将 SYS 寄存器的 bit 1(二进制 00000010)清 0 + # 先取反(~),然后按位与(&=):要清 0 的位写 0,其他位保持不变 + val &= ~0b00000010 # 清除 CRC 使能位 + # 将修改后的值写回 SYS 寄存器 + self.write_reg(ADS124S08_SYS, val) + + def _calculate_crc(self, data_bytes: list[int]) -> int: + """计算数据字节的 CRC-8-ATM (HEC) 校验值。 + + 这是 CRC-8-ATM 标准(也称为 HEC,Header Error Control), + 使用多项式 x^8 + x^2 + x + 1(对应二进制 0x07)。 + ADS124S08 芯片也使用同样的 CRC 算法,所以我们需要用同样的算法 + 计算数据的 CRC,然后与芯片发送过来的 CRC 字节比较,以验证数据的完整性。 + + 参数: + data_bytes: 要计算 CRC 的数据字节列表(每个元素 0~255) + + 返回: + 计算出的 CRC 值(0~255 的整数) + """ + # CRC 初始值为 0 + crc = 0 + # CRC-8-ATM 的多项式(生成多项式),二进制为 00000111,即 x^8 + x^2 + x + 1 + poly = 0x07 # CRC-8-ATM 多项式 + # 遍历每个数据字节 + for byte in data_bytes: + # 将当前字节与 CRC 值按位异或(XOR),把当前字节"加入"CRC 计算 + crc ^= byte + # 对每个字节的 8 个位逐位处理 + for _ in range(8): + # 检查 CRC 的最高位(bit 7)是否为 1 + if crc & 0x80: + # 如果最高位为 1:左移一位,然后与多项式异或(做多项式除法) + crc = (crc << 1) ^ poly + else: + # 如果最高位为 0:直接左移一位(相当于多项式除法的商为 0) + crc <<= 1 + # 返回低 8 位作为最终的 CRC 值(& 0xFF 确保结果在 0~255 范围内) + return crc & 0xFF + + def cmd_read_data(self) -> int: + """主动读取 ADC 的转换结果(通过发送 RDATA 命令)。 + + 这个方法向 ADC 发送 RDATA(读取数据)命令,然后读取 3 个字节的转换数据。 + ADC 输出的数据是 24 位有符号整数,以大端(big-endian)格式排列, + 即第一个字节是最高位(MSB),第三个字节是最低位(LSB)。 + + 如果启用了 CRC 校验,还会多读一个 CRC 字节并进行校验。 + + 返回: + 24 位有符号整数表示的 ADC 转换原始值(范围:-8388608 ~ 8388607) + """ + # 如果启用了 CRC 校验,需要多发送和接收 1 个字节(CRC 字节) + if self.en_crc: + # 通过 SPI 发送 5 个字节: + # - 第 1 字节:RDATA 命令(0x12),告诉 ADC 把转换结果发回来 + # - 第 2~4 字节:填充 0x00,用于产生 SPI 时钟,让 ADC 把 24 位数据发回来 + # - 第 5 字节:填充 0x00,用于产生时钟让 ADC 把 CRC 字节发回来 + rx = self.spi.xfer2([ADS124S08_CMD_RDATA, 0x00, 0x00, 0x00, 0x00]) + # 检查收到的数据是否够 5 个字节 + if len(rx) < 5: + raise Exception("Short SPI read from device") + # 合并 3 个数据字节为 24 位整数(大端格式): + # 第 1 个字节(rx[1])左移 16 位作为最高 8 位 + # 第 2 个字节(rx[2])左移 8 位作为中间 8 位 + # 第 3 个字节(rx[3])直接作为最低 8 位 + data = (rx[1] << 16) | (rx[2] << 8) | rx[3] + # 第 5 个字节(rx[4])是芯片计算的 CRC 校验值 + crc_received = rx[4] & 0xFF + # 对收到的 3 个数据字节重新计算 CRC,与芯片发送的 CRC 比较 + crc_calculated = self._calculate_crc([rx[1], rx[2], rx[3]]) + # 如果计算出的 CRC 与收到的 CRC 不一致,说明数据传输过程中发生了错误 + if crc_calculated != crc_received: + raise Exception(f"CRC mismatch: calculated 0x{crc_calculated:02x}, received 0x{crc_received:02x}, data: 0x{data:06x}") + # CRC 校验通过,将数据赋给 val + val = data + else: + # 未启用 CRC 校验,发送 4 个字节(少一个 CRC 字节): + # - 第 1 字节:RDATA 命令(0x12) + # - 第 2~4 字节:填充 0x00,用于产生 SPI 时钟读取 24 位数据 + rx = self.spi.xfer2([ADS124S08_CMD_RDATA, 0x00, 0x00, 0x00]) + # 检查收到的数据是否够 4 个字节 + if len(rx) < 4: + raise Exception("Short SPI read from device") + # 合并 3 个数据字节为 24 位整数(大端格式) + val = (rx[1] << 16) | (rx[2] << 8) | rx[3] + # 被注释掉的行:原来可能只取低 16 位(只使用 16 位分辨率),但实际使用了完整的 24 位 + # val = (rx[1] << 16) | (rx[2] << 8) | 0x00 + # 处理负数(符号扩展):如果最高位(bit 23)为 1,说明是负数 + if val & 0x800000: + # 将 24 位有符号数转换为 Python 的有符号整数: + # 减去 2^24(即 16777216),得到负数 + return val - (1 << 24) + # 如果是正数,直接返回 + return val + + def drdy_read_data(self) -> int: + """等待 DRDY 引脚变低后,直接读取 ADC 转换结果(不发送 RDATA 命令)。 + + 当 ADC 完成一次转换后,DRDY 引脚会自动变为低电平,表示数据已就绪。 + 此时不需要发送 RDATA 命令,直接发送 3 个(或 4 个,含 CRC)填充字节 + 来产生 SPI 时钟,ADC 就会把最新的转换结果发送回来。 + 这种方法比 cmd_read_data() 更高效,适合连续采样场景。 + + 注意:此方法不主动等待 DRDY 变低,调用者需要确保在调用此方法前 + DRDY 引脚已经变为低电平(即数据已就绪)。 + + 返回: + 24 位有符号整数表示的 ADC 转换原始值(范围:-8388608 ~ 8388607) + """ + # 如果启用了 CRC 校验 + if self.en_crc: + # 发送 4 个填充字节(不需要 RDATA 命令,DRDY 变低表示数据已就绪): + # 第 1~3 字节用于产生 SPI 时钟读取 24 位数据 + # 第 4 字节用于读取 CRC + rx = self.spi.xfer2([0x00, 0x00, 0x00, 0x00]) + # 检查长度 + if len(rx) < 4: + raise Exception("Short SPI read from device") + # 合并 3 个数据字节为 24 位整数(大端格式) + data = (rx[0] << 16) | (rx[1] << 8) | rx[2] + # 获取 CRC 字节 + crc_received = rx[3] & 0xFF + # 计算并验证 CRC + crc_calculated = self._calculate_crc([rx[0], rx[1], rx[2]]) + if crc_calculated != crc_received: + raise Exception(f"CRC mismatch: calculated 0x{crc_calculated:02x}, received 0x{crc_received:02x}, data: 0x{data:06x}") + val = data + else: + # 未启用 CRC,发送 3 个填充字节读取 24 位数据 + rx = self.spi.xfer2([0x00, 0x00, 0x00]) + if len(rx) < 3: + raise Exception("Short SPI read from device") + # 合并为 24 位整数(大端格式) + val = (rx[0] << 16) | (rx[1] << 8) | rx[2] + # 被注释掉的行:原来可能只取低 16 位 + # val = (rx[0] << 16) | (rx[1] << 8) | 0x00 + # 处理负数(符号扩展) + if val & 0x800000: + return val - (1 << 24) + return val + + def convert_to_voltage(self, raw_value: int) -> float: + """将 ADC 的原始数值转换为实际的电压值(单位:伏特)。 + + ADS124S08 是一个 24 位 ADC,可以测量双极性信号。 + 转换公式为:电压 = 原始值 × LSB + 其中 LSB(Least Significant Bit,最低有效位)是 ADC 能分辨的最小电压变化。 + + LSB 的计算公式: + LSB = Vref / (Gain × 2^23) + 推导过程: + - 24 位 ADC 的总量程是 2^24 = 16777216 个码值 + - 因为是双极性(可测正负电压),有效量程是 -Vref/Gain 到 +Vref/Gain + - 双极性下,码值范围是 -2^23 到 2^23-1,共 2^24 个码值 + - 所以 LSB = (Vref/Gain - (-Vref/Gain)) / 2^24 = 2×Vref/Gain / 2^24 = Vref / (Gain × 2^23) + + 举例:Vref=2.5V,Gain=1 时,LSB = 2.5 / 8388608 ≈ 0.298 微伏 + + 参数: + raw_value: ADC 的原始 24 位有符号数值(由 cmd_read_data 或 drdy_read_data 返回) + + 返回: + 转换后的电压值,单位伏特(V) + """ + # 读取 REF(参考电压配置)寄存器,获取当前参考电压的设置 + ref_reg = self.read_reg(ADS124S08_REF) + # 从 REF 寄存器中提取 refsel(参考电压选择)字段(bit 3~2) + refsel = (ref_reg >> 2) & 0x03 + # 检查参考电压选择:如果 refsel == 2,表示使用芯片内部参考电压 + if refsel == 2: + # ADS124S08 的内部参考电压为 2.5V + vref = 2.5 # 使用内部参考电压 2.5V + else: + # 如果是外部参考电压,这里也假设为 2.5V(实际应根据外部电路确定) + vref = 2.5 # 假设外部参考电压也是 2.5V,实际项目中需要根据电路调整 + + # 读取 PGA(可编程增益放大器)寄存器,获取当前的增益设置 + gain_factors = [1, 2, 4, 8, 16, 32, 64, 128] + pga_reg = self.read_reg(ADS124S08_PGA) + # 从 PGA 寄存器提取 PGA 使能位(bit 3) + pga_en = (pga_reg >> 3) & 0x01 + if pga_en: + # 如果 PGA 已启用,从低 3 位(bit 2~0)读取增益编码,并查表得到实际增益值 + gain = gain_factors[pga_reg & 0x07] + else: + # 如果 PGA 未启用,增益为 1 倍(信号直通,不放大) + gain = 1 + + # 计算 LSB(最低有效位对应的电压值): + # LSB = Vref / (Gain × 2^23) + # - Vref:参考电压(伏特) + # - Gain:PGA 增益倍数 + # - 2^23 = 8388608,对应 24 位有符号 ADC 的一半量程 + lsb = vref / (gain * (1 << 23)) # 24 位双极性 ADC 的 LSB 计算 + + # 最终电压值 = ADC 原始数值 × LSB + return raw_value * lsb + + def set_ref(self, en_monitor: int = 0, en_bufp: int = 0, en_bufn: int = 1, refsel: int = 0, refcon: int = 0) -> None: + """配置 ADC 的参考电压(REF)寄存器。 + + 参考电压决定了 ADC 的测量范围。例如参考电压为 2.5V 时, + 双极性模式下可测量的范围是 -2.5V 到 +2.5V。 + 这个函数允许用户配置参考电压的监控、缓冲、来源和控制模式。 + + REF 寄存器的位定义(8 位): + - bit 7~6 (en_monitor):参考电压监控模式 + - 0:禁用监控 + - 1:仅 L0 使能 + - 2:L0 + L1 使能 + - 3:L0 + 电阻 + - bit 5 (en_bufp):正参考电压缓冲器(1=禁用缓冲器直通,0=使能缓冲器) + - bit 4 (en_bufn):负参考电压缓冲器(1=禁用缓冲器直通,0=使能缓冲器) + - bit 3~2 (refsel):参考电压源选择 + - 0:P0+N0(外部引脚对) + - 1:P1+N1(外部引脚对) + - 2:内部参考电压(2.5V) + - bit 1~0 (refcon):内部参考电压控制 + - 0:内部参考关闭 + - 1:内部参考开启(待机时可关闭以省电) + - 2:内部参考始终开启 + + 参数: + en_monitor: 参考电压监控模式(0~3) + en_bufp: 正参考电压缓冲器控制(0=使能,1=禁用) + en_bufn: 负参考电压缓冲器控制(0=使能,1=禁用) + refsel: 参考电压源选择(0~2) + refcon: 内部参考电压控制(0~2) + """ + # 校验参数是否在有效范围内 + if not (0 <= en_monitor <= 3): + raise ("en_monitor must be 0-3") + if not (0 <= en_bufp <= 1): + raise ("en_bufp must be 0 or 1") + if not (0 <= en_bufn <= 1): + raise ("en_bufn must be 0 or 1") + if not (0 <= refsel <= 2): + raise ("refsel must be 0-2") + if not (0 <= refcon <= 2): + raise ("refcon must be 0-2") + # 将各个参数拼接到 REF 寄存器的对应位上: + # REF 寄存器的位布局(8 位二进制数): + # bit 7~6 = en_monitor,左移 6 位 + # bit 5 = en_bufp, 左移 5 位 + # bit 4 = en_bufn, 左移 4 位 + # bit 3~2 = refsel, 左移 2 位 + # bit 1~0 = refcon, 不移位 + # 使用按位或(|)运算将各个字段组合成一个字节 + ref_val = ((en_monitor & 0x03) << 6) | ((en_bufp & 0x01) << 5) | ((en_bufn & 0x01) << 4) | \ + ((refsel & 0x03) << 2) | (refcon & 0x03) + # 将组合好的值写入 REF 寄存器(地址 0x05) + self.write_reg(ADS124S08_REF, ref_val) + + def check_ref(self) -> None: + """读取并打印 REF(参考电压)寄存器的当前配置。 + + 从 ADC 的 REF 寄存器读取当前值,解析出每个字段(监控模式、缓冲器设置、 + 参考电压选择、参考控制等),并以可读的文字形式打印到控制台。 + 方便调试时确认参考电压配置是否正确。 + """ + # 读取 REF 寄存器(地址 0x05)的当前值 + val = self.read_reg(ADS124S08_REF) + # 从寄存器值中提取 en_monitor 字段(bit 7~6) + en_monitor = (val >> 6) & 0x03 + # 提取 en_bufp 字段(bit 5) + en_bufp = (val >> 5) & 0x01 + # 提取 en_bufn 字段(bit 4) + en_bufn = (val >> 4) & 0x01 + # 提取 refsel 字段(bit 3~2) + refsel = (val >> 2) & 0x03 + # 提取 refcon 字段(bit 1~0) + refcon = val & 0x03 + + # 定义枚举值的文字描述,用于打印 + monitor_names = ['已禁用', 'L0 使能', 'L0+L1 使能', 'L0+电阻'] + refsel_names = ['P0+N0(外部引脚)', 'P1+N1(外部引脚)', '内部参考(2.5V)'] + refcon_names = ['内部参考关闭', '内部参考开启', '始终开启'] + + # 打印参考电压监控模式(如果索引超出范围则显示 None) + print(f"参考电压监控: {monitor_names[en_monitor] if en_monitor < len(monitor_names) else None}") + # 打印正参考电压缓冲器状态:en_bufp=0 表示使能,=1 表示禁用 + print(f"正参考电压缓冲器: {'已禁用' if en_bufp else '已使能'}") + # 打印负参考电压缓冲器状态 + print(f"负参考电压缓冲器: {'已禁用' if en_bufn else '已使能'}") + # 打印参考电压源选择 + print(f"参考电压源: {refsel_names[refsel] if refsel < len(refsel_names) else '未知'}") + # 打印内部参考电压控制模式 + print(f"参考电压控制: {refcon_names[refcon] if refcon < len(refcon_names) else '未知'}") + + def set_input_mux(self, pos_channel: int, neg_channel: int = ADS124S08_AINCOM) -> None: + """配置输入多路复用器(INPUT MUX),选择要测量的模拟输入引脚。 + + ADS124S08 有 12 个模拟输入引脚(AIN0 ~ AIN11),通过输入多路复用器, + 我们可以选择哪两个引脚连接到 ADC 内部进行测量。 + + 两种模式: + - 单端测量:pos_channel = 要测量的引脚(如 AIN0),neg_channel = AINCOM(公共端) + 此时测量的是该引脚相对于公共端的电压。 + - 差分测量:pos_channel = 正输入端,neg_channel = 负输入端 + 此时测量的是两个引脚之间的电压差。 + + INPUT_MUX 寄存器布局(8 位): + - bit 7~4:正输入通道号(pos_channel) + - bit 3~0:负输入通道号(neg_channel) + + 参数: + pos_channel: 正输入端通道号(AIN0~AIN11,或 AINCOM=0x0C) + neg_channel: 负输入端通道号,默认 AINCOM(适用于单端测量) + """ + # 将正输入通道号放在高 4 位(左移 4 位),负输入通道号放在低 4 位, + # 然后按位或(|)组合成一个字节 + mux_val = ((pos_channel & 0x0F) << 4) | (neg_channel & 0x0F) + # 将组合后的值写入 INPUT_MUX 寄存器(地址 0x02) + self.write_reg(ADS124S08_INPUT_MUX, mux_val) + + def check_input_mux(self) -> None: + """读取并打印 INPUT_MUX(输入多路复用器)寄存器的当前配置。 + + 用于调试时确认当前选择的是哪个输入通道。 + """ + # 读取 INPUT_MUX 寄存器(地址 0x02)的当前值 + val = self.read_reg(ADS124S08_INPUT_MUX) + # 高 4 位是正输入端通道号,低 4 位是负输入端通道号,以十六进制打印 + print(f"正输入端: AIN{(val >> 4) & 0x0F}, 负输入端: AIN{val & 0x0F}") + + def set_pga(self, pga_en: int = 0, gain: int = 0) -> None: + """配置 PGA(可编程增益放大器)。 + + PGA 可以在 ADC 转换之前对模拟输入信号进行放大,这样即使是很微弱的信号 + (如应变片输出的毫伏级电压)也能被 ADC 准确测量。 + 增益越大,可测量的信号幅度越小,但分辨率越高。 + + PGA 寄存器的位布局(8 位): + - bit 3:PGA 使能位(1=使能,0=禁用) + - bit 2~0:增益选择码 + + 增益编码对应表: + 0: ×1 倍 1: ×2 倍 2: ×4 倍 3: ×8 倍 + 4: ×16 倍 5: ×32 倍 6: ×64 倍 7: ×128 倍 + + 参数: + pga_en: PGA 使能(1=启用前置放大,0=禁用,信号直通) + gain: 增益编码(0~7),对应 ×1 到 ×128 倍放大 + """ + # 校验参数是否在有效范围内 + if not (0 <= pga_en <= 1): + raise ("pga_en must be 0 or 1") + if not (0 <= gain <= 7): + raise ("gain must be 0-7") + # 将 PGA 使能位放在 bit 3,增益编码放在 bit 2~0,组合成一个字节 + pga_val = ((pga_en & 0x01) << 3) | (gain & 0x07) + # 将组合后的值写入 PGA 寄存器(地址 0x03) + self.write_reg(ADS124S08_PGA, pga_val) + + def check_pga(self) -> None: + """读取并打印 PGA(可编程增益放大器)寄存器的当前配置。 + + 用于调试时确认当前的增益设置是否正确。 + """ + # 读取 PGA 寄存器(地址 0x03)的当前值 + val = self.read_reg(ADS124S08_PGA) + # 提取低 3 位作为增益编码 + gain_val = val & 0x07 + # 增益编码对应的实际放大倍数 + gain_factors = [1, 2, 4, 8, 16, 32, 64, 128] + # 打印 PGA 使能状态和实际增益倍数 + print(f"PGA 使能: {(val >> 3) & 0x01}, 增益: ×{gain_factors[gain_val]}") + + def set_datarate(self, dr: int = 4, filter_type: int = 1, + mode: int = 0) -> None: + """配置 DATA_RATE(数据速率)寄存器,设置 ADC 的采样速度和转换模式。 + + 数据速率决定了 ADC 每秒进行多少次采样。速率越高,采样越快, + 但噪声也越大(信噪比降低)。速率越低,采样越慢,但精度越高。 + + DATA_RATE 寄存器的位布局(8 位): + - bit 7~6:保留位(未使用) + - bit 5:转换模式(0=连续转换,1=单次转换) + - bit 4:滤波器类型(0=Sinc3 滤波器,1=低延迟滤波器) + - bit 3~0:数据速率编码 + + 数据速率编码对应表(编码 0~13): + 0: 2.5 SPS 1: 5 SPS 2: 10 SPS 3: 16.6 SPS + 4: 20 SPS 5: 50 SPS 6: 60 SPS 7: 100 SPS + 8: 200 SPS 9: 400 SPS 10: 800 SPS 11: 1000 SPS + 12: 2000 SPS 13: 4000 SPS + + 参数: + dr: 数据速率编码(0~13) + filter_type: 滤波器类型(0=Sinc3,1=低延迟滤波器) + - Sinc3 滤波器:噪声低但转换速度慢,适合高精度测量 + - 低延迟滤波器:响应快但噪声略高,适合需要快速读取的场景 + mode: 转换模式(0=连续转换,1=单次转换) + - 连续模式:ADC 持续不断地进行转换,每次完成后自动开始下一次 + - 单次模式:每次转换完成后停止,需要再次发送 START 命令才继续转换 + """ + # 校验参数在有效范围内 + if not (0 <= dr <= 13): + raise Exception("dr must be 0-13") + if not (0 <= filter_type <= 1): + raise Exception("filter_type must be 0-1") + if not (0 <= mode <= 1): + raise Exception("mode must be 0-1") + + # 将 mode 放在 bit 5,filter_type 放在 bit 4,dr 编码放在 bit 3~0 + dr_val = ((mode & 0x01) << 5) | ((filter_type & 0x01) << 4) | (dr & 0x0F) + # 将组合后的值写入 DATA_RATE 寄存器(地址 0x04) + self.write_reg(ADS124S08_DATA_RATE, dr_val) + + def check_datarate(self) -> None: + """读取并打印 DATA_RATE(数据速率)寄存器的当前配置。 + + 用于调试时确认当前的数据速率、滤波器类型和转换模式。 + """ + # 读取 DATA_RATE 寄存器(地址 0x04)的当前值 + val = self.read_reg(ADS124S08_DATA_RATE) + # 提取低 4 位作为数据速率编码 + dr = val & 0x0F + # 提取 bit 4 作为滤波器类型 + filter_type = (val >> 4) & 0x01 + # 提取 bit 5 作为转换模式 + mode = (val >> 5) & 0x01 + + # 数据速率编码对应的实际每秒采样次数(SPS = Samples Per Second) + dr_values = [2.5, 5, 10, 16.6, 20, 50, 60, 100, 200, 400, 800, 1000, 2000, 4000] + # 滤波器类型名称 + filter_names = ['Sinc3 滤波器', '低延迟滤波器'] + # 转换模式名称 + mode_names = ['连续转换', '单次转换'] + + # 打印数据速率(如果编码超出范围则显示 None) + print(f"数据速率: {dr_values[dr] if dr < len(dr_values) else None} SPS") + # 打印滤波器类型 + print(f"滤波器类型: {filter_names[filter_type] if filter_type < len(filter_names) else None}") + # 打印转换模式 + print(f"转换模式: {mode_names[mode] if mode < len(mode_names) else None}") + + def check_status(self) -> None: + """读取并打印 STATUS(状态)寄存器的当前值,检查 ADC 工作状态。 + + STATUS 寄存器的位含义: + - bit 7:上电复位标志(POR,Power-On Reset),1 表示芯片发生过上电复位 + - bit 6:ADC 未就绪标志,1 表示 ADC 还没有准备好进行转换 + - bit 5~0:其他状态标志位 + + 通过检查这些标志可以判断 ADC 是否工作正常。 + """ + # 读取 STATUS 寄存器(地址 0x01)的当前值 + val = self.read_reg(ADS124S08_STATUS) + # 检查 bit 7:上电复位标志 + if val & 0b10000000: + print("检测到上电复位(POR),需要清除该标志") + # 检查 bit 6:ADC 就绪标志 + if val & 0b01000000: + print("ADC 尚未就绪,可能还在初始化中") + # 检查低 6 位的其他标志位 + if val & 0b00111111: + print("ADC 状态标志:", bin(val & 0b00111111)) + + def clear_por(self) -> None: + """清除 STATUS 寄存器的上电复位(POR)标志位。 + + 在芯片上电复位后,STATUS 寄存器的 bit 7 会被设为 1, + 表示发生过上电复位。向 STATUS 寄存器写入 0x00 可以将这个标志清零。 + 之后程序就可以通过检查这个标志来判断芯片是否意外掉电重启过。 + """ + # 向 STATUS 寄存器(地址 0x01)写入 0x00,将所有标志清零 + self.write_reg(ADS124S08_STATUS, 0x00) + + def request_channels(self, channels: list[int]) -> list[int]: + """按顺序逐个测量指定的多个单端通道,并返回测量结果。 + + 此方法适用于单次转换模式(Single-shot mode),它会: + 1. 检查 ADC 是否配置为单次转换模式 + 2. 对于列表中的每个通道,切换到该通道,启动一次转换,等待转换完成,读取数据 + 3. 收集所有通道的原始 ADC 值并返回 + + 参数: + channels: 要测量的通道号列表,例如 [0, 1, 2] 表示测量 AIN0、AIN1、AIN2 + 每个通道对应单端测量(以 AINCOM 为公共端) + + 返回: + 每个通道的原始 ADC 值列表(24 位有符号整数),顺序与输入 channels 相同 + """ + # 读取 DATA_RATE 寄存器,检查当前是否配置为单次转换模式 + val = self.read_reg(ADS124S08_DATA_RATE) + # 检查 DATA_RATE 寄存器的 bit 5:0=连续模式,1=单次模式 + if val & 0b00100000: + # 如果是单次模式,直接通过(pass 语句什么都不做) + pass # 设备已处于单次转换模式 + else: + # 如果是连续模式,抛出异常,因为此方法只适用于单次模式 + raise Exception("设备未处于单次转换模式") + + # 数据速率编码到实际 SPS 的映射表 + dr_values = [2.5, 5, 10, 16.6, 20, 50, 60, 100, 200, 400, 800, 1000, 2000, 4000] + # 计算等待时间:1 秒 ÷ 每秒采样次数 + 额外 1 毫秒的余量 + # 例如 20 SPS 时,每次转换需要 1/20 = 50ms,再加上 1ms 余量 + wait_time = 1.0 / dr_values[val & 0x0F] + 0.001 # 增加一点余量,确保转换完成 + # 创建一个空列表,用于存储每个通道的测量结果 + values = [] + # 遍历每个要测量的通道 + for ch in channels: + # 将输入多路复用器切换到当前通道(单端模式,负端接 AINCOM) + self.set_input_mux(pos_channel=ch, neg_channel=ADS124S08_AINCOM) + # 如果配置了 DRDY 引脚并且成功创建了 Button 对象 + if self.drdy_pin and self._drdy_pin is not None: + # 发送 START 命令,启动一次单次转换 + self.write_cmd(ADS124S08_CMD_START) + # 被注释掉的代码:原来的实现可能用 wait_for_press() 等待 DRDY 引脚变低 + # 但被替换为固定延时,因为 DRDY 引脚检测在某些情况下可能不稳定 + # time.sleep(0.001) + # self._drdy_pin.wait_for_press() + # 等待足够长的时间让 ADC 完成转换 + time.sleep(wait_time) + # 读取转换结果 + val = self.cmd_read_data() + else: + # 如果没有配置 DRDY 引脚,直接发送 START 命令 + self.write_cmd(ADS124S08_CMD_START) + # 等待转换完成 + time.sleep(wait_time) + # 读取转换结果 + val = self.cmd_read_data() + # 将当前通道的测量值添加到结果列表中 + values.append(val) + # 返回所有通道的测量结果列表 + return values \ No newline at end of file diff --git a/drv_encodermotor.py b/drv_encodermotor.py new file mode 100644 index 0000000..5fb8453 --- /dev/null +++ b/drv_encodermotor.py @@ -0,0 +1,673 @@ +""" +树莓派 5 (Raspberry Pi 5) 上控制 M5Stack Module 4EncoderMotor 的 I2C 驱动程序。 + +模块功能: + 本驱动用于通过 I2C 总线控制 M5Stack 的 4EncoderMotor 模块。该模块可同时驱动 + 4 个空心杯电机(带编码器反馈的小型直流电机),常用于旋转圆柱、机械臂关节等场景。 + 每个电机都可以独立设置转速/位置模式、读取编码器数值、读取电流等。 + +I2C 通信简介: + I2C(Inter-Integrated Circuit)是一种串行通信协议,仅需两根线(SDA 数据线 和 + SCL 时钟线)即可在多个设备之间传输数据。总线上每个设备都有一个唯一地址,主机 + (这里是树莓派)通过地址选择要通信的从设备。本模块的 I2C 地址固定为 0x24。 + +资源依赖: + 需要安装 smbus2 库(Python 的 I2C 操作库)。 + +参考文档: + https://docs.m5stack.com/en/module/Module_4EncoderMotor_V1.1 + +用法示例: + from drv_encodermotor import EncoderMotorDriver, NORMAL_MODE + with EncoderMotorDriver(bus=1) as driver: + driver.set_mode(0, NORMAL_MODE) + driver.set_motor_speed(0, 50) +""" + +# 从 __future__ 模块导入 annotations 特性,允许在类型注解中使用字符串形式的类名 +# (例如在方法返回类型中引用自身类 "EncoderMotorDriver"),从而避免循环导入问题 +from __future__ import annotations + +# 导入 struct 模块,用于将原始字节数据解析为整数、浮点数等 Python 数据类型 +# 本驱动中使用它来解析 I2C 读取回来的多字节数据(如编码器数值、电流值) +import struct +# 从 typing 模块导入 List 类型注解,用于提示函数参数是列表类型,提高代码可读性 +from typing import List + +# 从 smbus2 库导入 SMBus 类,这是树莓派上操作 I2C 总线的 Python 封装库 +# SMBus 提供了读写单个字节、读写数据块等方法 +from smbus2 import SMBus + + +# ============================================================================= +# I2C 寄存器地址常量定义 +# 这些地址来源于 M5Stack 官方 Arduino 示例代码,每个地址对应模块内部的一个寄存器 +# 通过向这些寄存器写入或读取数据,即可控制电机或获取传感器信息 +# ============================================================================= + +# 模块的 I2C 从机地址,固定为 0x24(十六进制,十进制为 36) +MODULE_4ENCODERMOTOR_ADDR = 0x24 + +# 舵机角度寄存器起始地址 = 0x00,用于设置舵机的目标角度 +# (第 1 个电机从 0x00 开始,第 2 个电机从 0x04 开始,依此类推) +MODULE_4ENCODERMOTOR_SERVO_ANGLE_ADDR = 0x00 +# 舵机脉冲宽度寄存器起始地址 = 0x10,用于直接设置舵机的 PWM 脉冲宽度 +MODULE_4ENCODERMOTOR_SERVO_PULSE_ADDR = 0x10 +# PWM 占空比寄存器起始地址 = 0x20,用于设置电机的 PWM 占空比(即电机转速) +# 第 1 个电机对应 0x20,第 2 个对应 0x21,以此类推(每个电机占 1 字节) +MODULE_4ENCODERMOTOR_PWM_DUTY_ADDR = 0x20 +# 编码器数值寄存器起始地址 = 0x30,用于读取或设置电机的编码器计数值 +# 编码器是装在电机轴上的传感器,可以测量电机旋转的角度/圈数 +# 每个电机编码器占 4 字节(32 位有符号整数) +MODULE_4ENCODERMOTOR_ENCODER_ADDR = 0x30 +# 速度寄存器起始地址 = 0x40,用于读取电机每 20ms 的速度值(1 字节) +MODULE_4ENCODERMOTOR_SPEED_ADDR = 0x40 +# 8 位 ADC(模数转换器)寄存器地址 = 0xA0,用于读取模拟输入引脚的值(8 位精度) +MODULE_4ENCODERMOTOR_ADC_8BIT_REG = 0xA0 +# 12 位 ADC 寄存器地址 = 0xB0,用于读取模拟输入引脚的值(12 位精度,更精确) +MODULE_4ENCODERMOTOR_ADC_12BIT_REG = 0xB0 +# 跳转到 Bootloader(引导加载程序)的寄存器地址 = 0xFD,用于进入固件升级模式 +JUMP_TO_BOOTLOADER_REG = 0xFD +# 升级 Bootloader 的寄存器地址 = 0xE0,用于触发 Bootloader 更新流程 +UPGRADE_BOOTLOADER_REG = 0xE0 +# 固件版本号寄存器地址 = 0xFE,读取此地址可获得模块当前固件版本 +MODULE_4ENCODERMOTOR_FIRMWARE_VERSION_ADDR = 0xFE +# Bootloader 版本号寄存器地址 = 0xFC,读取此地址可获得 Bootloader 版本 +MODULE_4ENCODERMOTOR_BOOTLOADER_VERSION_ADDR = 0xFC +# I2C 地址配置寄存器地址 = 0xFF,用于读取或修改模块的 I2C 地址 +MODULE_4ENCODERMOTOR_I2C_ADDRESS_ADDR = 0xFF + +# 配置块寄存器起始地址 = 0x50,用于设置电机的控制模式、PID 参数等 +MODULE_4ENCODERMOTOR_CONFIG_ADDR = 0x50 +# 电流寄存器地址 = 0x90,用于读取当前电机总电流(4 字节浮点数) +MODULE_4ENCODERMOTOR_CURRENT_ADDR = 0x90 +# 软启停寄存器地址 = 0xD1,用于启用或禁用电机的软启动/软停止功能 +MODULE_4ENCODERMOTOR_SOFT_START_STOP_ADDR = 0xD1 + + +# ============================================================================= +# 电机工作模式常量定义 +# ============================================================================= + +# 普通模式 = 0x00,直接通过 PWM 占空比控制电机转速,不做闭环控制 +NORMAL_MODE = 0x00 +# 位置模式 = 0x01,使用 PID 闭环控制,使电机旋转到指定位置 +POSITION_MODE = 0x01 +# 速度模式 = 0x02,使用 PID 闭环控制,使电机保持指定转速 +SPEED_MODE = 0x02 +# IAP(在应用中编程)更新模式 = 0x03,用于在线升级固件 +IAP_UPDATE_MODE = 0x03 +# Bootloader 更新模式 = 0x04,用于通过 Bootloader 升级固件 +BOOTLOADER_UPDATE_MODE = 0x04 + + +def _constrain_index(index: int) -> int: + """ + 将电机索引限制在 0~3 范围内(4 个电机),防止越界访问。 + + 参数: + index: 原始电机索引号(用户传入的,可能是负数或大于 3 的数) + 返回: + 限制后的有效索引值,范围 0~3 + """ + # 如果索引小于 0,则返回 0(第 1 个电机),避免负数索引导致错误 + if index < 0: + return 0 + # 如果索引大于 3,则返回 3(第 4 个电机),避免超出模块支持的电机数量 + if index > 3: + return 3 + # 索引在有效范围内(0~3),直接返回原值 + return index + + +class EncoderMotorError(RuntimeError): + """ + 自定义异常类,继承自 RuntimeError。 + 当 I2C 通信失败或参数超出范围时抛出此异常,方便调用方捕获并处理错误。 + """ + # pass 表示这是一个空类,不添加额外的方法或属性 + # 只改变异常的名字,便于区分是哪个模块出的错误 + pass + + +class EncoderMotorDriver: + """树莓派上控制 M5Stack 4EncoderMotor 模块的最小化驱动程序。 + + 本类尽量遵循 Arduino 示例代码中的寄存器布局和字节序约定,以保证不同平台 + (Arduino 和树莓派)之间的兼容性。 + + 关于字节序(Byte Order)的说明(以匹配 Arduino 实现): + - 编码器寄存器(MODULE_4ENCODERMOTOR_ENCODER_ADDR):使用大端序(big-endian) + 有符号 32 位整数。Arduino 代码中将字节按 b0<<24 | b1<<16 | b2<<8 | b3 + 的方式拼接,因此我们从树莓派端读取时需要按大端序解析。 + - 配置块中的位置点(setPositionPoint):使用小端序(little-endian)写入, + Arduino 代码中先发送最低有效字节(LSB first)。 + - 电流浮点数:以 4 个原始字节传输,采用模块本身的浮点数表示方式。 + Arduino 的 memcpy 表明是小端序浮点排列,因此我们用 ' None: + """ + 关闭 I2C 总线连接,释放资源。 + 使用完驱动后应调用此方法,或使用 with 语句让 Python 自动调用。 + """ + try: + # 检查总线对象是否不为 None,避免重复关闭导致异常 + if self.bus is not None: + # 调用 SMBus 对象的 close 方法,关闭 I2C 总线 + self.bus.close() + finally: + # 无论 close 是否成功,都将总线对象设为 None,标记为已关闭 + # type: ignore 是告诉类型检查器忽略此处类型不匹配的警告 + self.bus = None # type: ignore + + def __enter__(self) -> "EncoderMotorDriver": + """ + Python 上下文管理器协议的方法——进入 with 语句块时调用。 + 配合 __exit__ 使用,使该类支持 with 语句(如 with EncoderMotorDriver() as drv:)。 + 返回 self,这样在 with 块内就可以用 drv 变量来调用其他方法。 + """ + return self + + def __exit__(self, exc_type, exc, tb) -> None: + """ + Python 上下文管理器协议的方法——退出 with 语句块时调用。 + 无论 with 块中是否发生异常,都会自动关闭 I2C 总线连接。 + + 参数: + exc_type: 异常类型(如果没有异常则为 None) + exc: 异常对象(如果没有异常则为 None) + tb: 异常回溯信息(如果没有异常则为 None) + """ + # 调用 close 方法关闭 I2C 总线 + self.close() + + # ========================================================================= + # 底层 I2C 读写辅助方法 + # 这些方法封装了 smbus2 库的基本 I2C 操作,加上错误处理,方便上层方法调用 + # ========================================================================= + + def _write_byte(self, reg: int, value: int) -> None: + """ + 向指定寄存器写入一个字节的数据。 + + 参数: + reg: 目标寄存器地址(如 0x20 表示 PWM 占空比寄存器) + value: 要写入的整数值(只取低 8 位,高 24 位被截断) + """ + # 将 value 与 0xFF 做按位与运算,只保留低 8 位(一个字节) + # 例如 value=0x1A3 时,0x1A3 & 0xFF = 0xA3,只取最低一个字节 + value &= 0xFF + try: + # 通过 I2C 向模块地址 self.addr 的寄存器 reg 写入一个字节 value + # write_byte_data(地址, 寄存器, 数据) 是 smbus2 的标准方法 + self.bus.write_byte_data(self.addr, reg, value) + except Exception as e: + # 如果 I2C 写入过程发生任何异常(如设备未连接、总线错误等), + # 抛出自定义的 EncoderMotorError 异常,附带错误描述和原始异常信息 + raise EncoderMotorError(f"I2C write_byte failed: {e}") + + def _write_block(self, reg: int, data: List[int]) -> None: + """ + 向指定寄存器写入多个字节的数据(数据块)。 + + 参数: + reg: 起始寄存器地址 + data: 要写入的整数列表,每个整数代表一个字节 + """ + # 将 data 列表中的每个元素都与 0xFF 做按位与运算,确保每个值都在 0~255 范围内 + # 这相当于只取每个元素的低 8 位 + data = [int(x) & 0xFF for x in data] + try: + # 通过 I2C 向模块地址 self.addr 的起始寄存器 reg 写入 data 列表 + # write_i2c_block_data(地址, 起始寄存器, 数据列表) 会依次写入多个字节 + # 第一个字节写入 reg,第二个字节写入 reg+1,依此类推 + self.bus.write_i2c_block_data(self.addr, reg, data) + except Exception as e: + # I2C 写入失败时,抛出自定义异常 + raise EncoderMotorError(f"I2C write_block failed: {e}") + + def _read_block(self, reg: int, length: int) -> bytes: + """ + 从指定寄存器开始读取多个字节的数据。 + + 参数: + reg: 起始寄存器地址 + length: 要读取的字节数 + 返回: + bytes 类型的字节序列,包含从模块读取的原始数据 + """ + try: + # 通过 I2C 从模块地址 self.addr 的起始寄存器 reg 读取 length 个字节 + # read_i2c_block_data 返回一个整数列表,每个元素代表一个字节的值(0~255) + data = self.bus.read_i2c_block_data(self.addr, reg, length) + # 将整数列表转换为 bytes 对象(不可变的字节序列),方便后续用 struct 解析 + return bytes(data) + except Exception as e: + # I2C 读取失败时,抛出自定义异常 + raise EncoderMotorError(f"I2C read_block failed: {e}") + + # ========================================================================= + # 上层 API 方法(命名和行为尽量与 Arduino 示例保持一致) + # ========================================================================= + + def set_mode(self, index: int, mode: int) -> None: + """ + 设置指定电机的控制模式。 + + 参数: + index: 电机索引(0~3,分别对应模块上的 4 个电机接口) + mode: 工作模式,可以是 NORMAL_MODE(0x00)、POSITION_MODE(0x01) 等 + """ + # 将电机索引限制在 0~3 范围内,防止越界 + index = _constrain_index(index) + # 计算配置寄存器的地址:基地址 0x50 + 0x10 * 电机索引 + # 每个电机在配置块中占用 16 字节(0x10)的空间 + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + (0x10 * index) + # 通过 I2C 向计算出的配置寄存器地址写入模式值(1 字节) + self._write_byte(reg, mode) + + def get_encoder_value(self, index: int) -> int: + """ + 读取指定电机的编码器数值。 + + 编码器是安装在电机轴上的传感器,电机每转一圈,编码器会产生固定数量的脉冲。 + 通过统计脉冲数,可以知道电机轴当前的位置(角度)。 + + 参数: + index: 电机索引(0~3) + 返回: + 编码器的 32 位有符号整数值,正值表示正转,负值表示反转 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算编码器寄存器地址:基地址 0x30 + 4 * 电机索引 + # 每个电机的编码器数值占用 4 字节(32 位整数),所以索引要乘以 4 + reg = MODULE_4ENCODERMOTOR_ENCODER_ADDR + 4 * index + # 通过 I2C 从 reg 地址开始连续读取 4 个字节的原始数据 + data = self._read_block(reg, 4) + # 使用 struct.unpack 将 4 字节解析为有符号 32 位整数 + # '>i' 的含义: + # '>' 表示大端序(big-endian),即高位字节在前 + # 'i' 表示有符号 32 位整数(C 语言的 int32_t) + # Arduino 代码中将字节按 (b0<<24) | (b1<<16) | (b2<<8) | b3 拼接, + # 这正是大端序的组装方式,所以我们用大端序解析 + return struct.unpack('>i', data)[0] + + def set_encoder_value(self, index: int, encoder: int) -> None: + """ + 设置指定电机的编码器数值(写入/重置编码器计数值)。 + + 通常用于将编码器归零(写入 0)或设置初始位置。 + + 参数: + index: 电机索引(0~3) + encoder: 要写入的 32 位有符号整数值(编码器计数值) + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算编码器寄存器地址:基地址 0x30 + 4 * 电机索引 + reg = MODULE_4ENCODERMOTOR_ENCODER_ADDR + 4 * index + # 将 32 位整数拆分为 4 个字节,按大端序排列(与 Arduino 一致) + # 拆分方法: + # 第 1 字节 = (encoder >> 24) & 0xFF 取最高 8 位 + # 第 2 字节 = (encoder >> 16) & 0xFF 取次高 8 位 + # 第 3 字节 = (encoder >> 8) & 0xFF 取次低 8 位 + # 第 4 字节 = encoder & 0xFF 取最低 8 位 + data = [ + (encoder >> 24) & 0xFF, + (encoder >> 16) & 0xFF, + (encoder >> 8) & 0xFF, + encoder & 0xFF + ] + # 通过 I2C 向编码器寄存器写入 4 个字节的数据 + self._write_block(reg, data) + + def set_motor_speed(self, index: int, duty: int) -> None: + """ + 设置指定电机的 PWM 占空比(即电机转速)。 + 此方法适用于 NORMAL_MODE(普通模式),直接控制 PWM 输出。 + + PWM(脉冲宽度调制):通过快速开关电源来调节电机的平均电压, + 占空比越高,电机转速越快。正值为正转,负值为反转。 + + 参数: + index: 电机索引(0~3) + duty: 占空比值,取值范围 -128~127(有符号 8 位整数) + 正数表示正转,负数表示反转,数值绝对值越大转速越快 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算 PWM 占空比寄存器地址:基地址 0x20 + 电机索引 + # 每个电机占 1 个字节,所以直接加索引值即可 + reg = MODULE_4ENCODERMOTOR_PWM_DUTY_ADDR + index + # 将 duty 与 0xFF 做按位与运算,只保留低 8 位 + # 注意:duty 是带符号的整数(如 -50 的补码表示为 0xCE), + # 与 0xFF 后可以得到正确的二进制补码表示 + self._write_byte(reg, duty & 0xFF) + + def get_motor_speed(self, index: int) -> int: + """ + 读取指定电机的当前 PWM 占空比(转速设定值)。 + + 参数: + index: 电机索引(0~3) + 返回: + 有符号 8 位整数(-128~127),表示当前的 PWM 占空比设定值 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算 PWM 占空比寄存器地址:基地址 0x20 + 电机索引 + reg = MODULE_4ENCODERMOTOR_PWM_DUTY_ADDR + index + # 通过 I2C 从寄存器读取 1 个字节的原始数据 + data = self._read_block(reg, 1) + # 使用 struct.unpack 将单个字节解析为有符号 8 位整数 + # 'b' 表示有符号字符(signed char),即 -128~127 的范围 + # 这样 -50 这样的负值就能正确解析,而不是变成 206(无符号值) + return struct.unpack('b', data)[0] + + def get_motor_speed_20ms(self, index: int) -> int: + """ + 读取指定电机每 20 毫秒的速度反馈值(编码器测速结果)。 + 这个值代表电机在当前 20ms 时间窗口内的平均速度。 + + 参数: + index: 电机索引(0~3) + 返回: + 有符号 8 位整数(-128~127),正值正转,负值反转 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算速度反馈寄存器地址:基地址 0x40 + 电机索引 + # 每个电机占 1 个字节 + reg = MODULE_4ENCODERMOTOR_SPEED_ADDR + index + # 通过 I2C 从寄存器读取 1 个字节的原始数据 + data = self._read_block(reg, 1) + # 使用 struct.unpack 解析为有符号 8 位整数 + return struct.unpack('b', data)[0] + + def set_position_pid(self, index: int, kp: int, ki: int, kd: int) -> None: + """ + 设置指定电机位置模式下的 PID 参数。 + + PID(比例-积分-微分)控制是一种闭环控制算法: + - Kp(比例系数):根据当前误差的大小来调节输出,误差越大调节越强 + - Ki(积分系数):根据误差的累积来调节输出,消除稳态误差 + - Kd(微分系数):根据误差的变化趋势来调节输出,抑制超调 + + 参数: + index: 电机索引(0~3) + kp: 比例系数(0~255) + ki: 积分系数(0~255) + kd: 微分系数(0~255) + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算 PID 参数在配置块中的起始地址: + # 基地址 0x50 + 0x10 * 电机索引 + 0x01 偏移 + # 0x01 偏移表示 Kp 存储在配置块的第 2 个字节(第 1 个字节是模式设置) + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + index * 0x10 + 0x01 + # 通过 I2C 向起始寄存器写入 3 个字节:Kp、Ki、Kd 各占 1 字节 + # 每个参数都与 0xFF 取低 8 位,确保在 0~255 范围内 + self._write_block(reg, [kp & 0xFF, ki & 0xFF, kd & 0xFF]) + + def set_position_point(self, index: int, position_point: int) -> None: + """ + 设置指定电机在位置模式下的目标位置点。 + 电机将自动旋转到指定位置(使用 PID 闭环控制)。 + + 参数: + index: 电机索引(0~3) + position_point: 目标位置值(32 位有符号整数),以编码器脉冲数为单位 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算位置点在配置块中的起始地址: + # 基地址 0x50 + 0x10 * 电机索引 + 0x04 偏移 + # 0x04 偏移表示位置点存储在配置块的偏移 0x04 处 + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + index * 0x10 + 0x04 + # 将 32 位整数拆分为 4 个字节,按小端序排列(先写最低有效字节 LSB) + # 拆分方法(小端序): + # 第 1 字节 = position_point & 0xFF 取最低 8 位(先发送) + # 第 2 字节 = (position_point >> 8) & 0xFF + # 第 3 字节 = (position_point >> 16) & 0xFF + # 第 4 字节 = (position_point >> 24) & 0xFF 取最高 8 位(最后发送) + # Arduino 的 setPositionPoint 函数中先发送 LSB(最低有效字节),即小端序 + data = [ + position_point & 0xFF, + (position_point >> 8) & 0xFF, + (position_point >> 16) & 0xFF, + (position_point >> 24) & 0xFF + ] + # 通过 I2C 向位置点寄存器写入 4 个字节(小端序) + self._write_block(reg, data) + + def set_position_pid_max_speed(self, index: int, max_pwm: int) -> None: + """ + 设置位置模式下 PID 控制的最大允许 PWM 占空比(即最大转速限制)。 + 这样即使在位置 PID 计算出很大的输出时,也能限制电机不超过这个速度。 + + 参数: + index: 电机索引(0~3) + max_pwm: 最大 PWM 占空比(0~255),数值越大允许的最大转速越高 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算最大速度寄存器地址: + # 基地址 0x50 + 0x10 * 电机索引 + 0x08 偏移 + # 0x08 偏移对应 Arduino 示例中最大速度参数的存储位置 + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + index * 0x10 + 0x08 + # 通过 I2C 向寄存器写入最大 PWM 值(1 字节),与 0xFF 确保在范围内 + self._write_byte(reg, max_pwm) + + def set_speed_pid(self, index: int, kp: int, ki: int, kd: int) -> None: + """ + 设置指定电机在速度模式下的 PID 参数。 + 当使用 SPEED_MODE(速度模式)时,电机通过 PID 闭环控制来维持目标转速。 + + 参数: + index: 电机索引(0~3) + kp: 比例系数(0~255) + ki: 积分系数(0~255) + kd: 微分系数(0~255) + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算速度 PID 参数在配置块中的起始地址: + # 基地址 0x50 + 0x10 * 电机索引 + 0x09 偏移 + # 0x09 偏移是速度 PID 参数的存储起始位置(紧跟在位置 PID 最大速度之后) + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + index * 0x10 + 0x09 + # 通过 I2C 向起始寄存器写入 3 个字节:Kp、Ki、Kd 各占 1 字节 + self._write_block(reg, [kp & 0xFF, ki & 0xFF, kd & 0xFF]) + + def set_speed_point(self, index: int, speed_point: int) -> None: + """ + 设置指定电机在速度模式下的目标速度值。 + 电机将通过 PID 闭环控制来达到并保持这个速度。 + + 参数: + index: 电机索引(0~3) + speed_point: 目标速度值(有符号 8 位整数,-128~127) + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 计算速度点在配置块中的地址: + # 基地址 0x50 + 0x10 * 电机索引 + 0x0C 偏移 + # 0x0C 偏移对应速度目标值的存储位置(紧跟在速度 PID 参数之后) + reg = MODULE_4ENCODERMOTOR_CONFIG_ADDR + index * 0x10 + 0x0C + # 通过 I2C 向寄存器写入速度目标值(1 字节),与 0xFF 取低 8 位 + self._write_byte(reg, speed_point & 0xFF) + + def set_linear_speed_m_s(self, index: int, speed_m_s: float) -> None: + """ + 设置指定电机的线速度(米/秒),将线速度换算为电机转速并写入模块。 + 适用于用电机驱动圆柱旋转的场景,已知圆柱的物理参数后可换算。 + + 速度换算公式推导(核心难点): + speed = int(speed_m_s / (3.1415 * 0.01) * 9 * 16 * 20 / 50) + + 公式中各数字的物理含义: + 3.1415 → 圆周率 π(约等于 3.14159),用于计算圆柱周长 + 0.01 → 圆柱半径 0.01 米(即 1 厘米),这是圆柱的物理半径 + ----------------------------------------------------------------- + 3.1415 * 0.01 = 圆柱周长(米/圈) + 圆柱周长 = 2 * π * 半径 = 2 * 3.1415 * 0.01 = 0.06283 米 + ⚠ 注意:代码中实际用的是 π * 半径 而不是 2 * π * 半径, + 这里需要结合实际的圆柱安装方式理解。如果圆柱是由电机通过摩擦 + 轮或减速机构驱动的,有效半径可能已经包含了减半的因素。 + ----------------------------------------------------------------- + speed_m_s / (3.1415 * 0.01) = 每秒旋转的圈数(转/秒) + 将线速度除以每圈周长,得到目标转速(圈/秒) + ----------------------------------------------------------------- + 9 → 电机减速比(减速箱的减速比 9:1) + 电机内部有减速齿轮,输出轴每转 1 圈,电机转子转 9 圈 + ----------------------------------------------------------------- + 16 → 编码器每圈的脉冲数(线数) + 编码器每转一圈产生 16 个脉冲,用于测量电机转子的位置 + ----------------------------------------------------------------- + 9 * 16 = 144 → 电机输出轴每转 1 圈,编码器产生的脉冲数 + ----------------------------------------------------------------- + 20 → 每 20 毫秒(即 0.02 秒)采样一次速度 + 模块每 20ms 读取一次编码器脉冲数,计算速度反馈值 + ----------------------------------------------------------------- + 50 → 速度寄存器每一单位对应的每秒脉冲数 + 模块内部速度值 = 每 20ms 的脉冲数 / 50 + 换句话说,速度寄存器中数值为 50 时,对应的 20ms 脉冲数为 + 50 × 20ms 的脉冲数... 这个 50 是模块固件定义的比例因子 + ----------------------------------------------------------------- + 完整计算流程: + 1. 线速度 → 每秒圈数:speed_m_s / (π * 半径) + 2. 每秒圈数 → 每秒电机转子圈数:× 减速比 9 + 3. 每秒转子圈数 → 每秒编码器脉冲数:× 编码器线数 16 + 4. 每秒脉冲数 → 每 20 毫秒脉冲数:÷ (1000/20) = ÷ 50 + 5. 最终速度值 = 每秒脉冲数 / 50 + ----------------------------------------------------------------- + 合并公式: + speed = speed_m_s / (π * 0.01) × 9 × 16 / 50 + = speed_m_s / (3.1415 * 0.01) * 9 * 16 / 50 + + 参数: + index: 电机索引(0~3) + speed_m_s: 目标线速度,单位米/秒(可以是小数,如 0.5 表示 0.5 米/秒) + """ + # 将线速度换算为模块速度寄存器所需的数值 + # speed_m_s / (3.1415 * 0.01) = 每秒圆柱转数(圈/秒) + # × 9 × 16 = 每秒编码器脉冲数(考虑减速比和编码器线数) + # / 50 = 每 20ms 脉冲数 / 50(模块内部速度单位换算) + speed = int(speed_m_s / (3.1415 * 0.01) * 9 * 16 * 20 / 50) + # 检查计算出的速度值是否在有符号 8 位整数的有效范围内(-127~127) + # 超出范围说明设定的线速度太高或太低,无法用 1 字节表示 + if speed > 127 or speed < -127: + # 如果速度超出范围,抛出异常提示调用方 + raise EncoderMotorError("Calculated speed out of range for set_linear_speed_m_s") + else: + # 如果速度在有效范围内,调用 set_speed_point 写入目标速度 + self.set_speed_point(index, speed) + + def get_motor_current(self) -> float: + """ + 读取模块的电机总电流(所有电机电流之和),单位安培(A)。 + 可用于监控电机是否过载或堵转。 + + 返回: + 浮点数,表示当前电机总电流,单位安培(A) + """ + # 通过 I2C 从电流寄存器 MODULE_4ENCODERMOTOR_CURRENT_ADDR (0x90) + # 连续读取 4 个字节的原始数据 + data = self._read_block(MODULE_4ENCODERMOTOR_CURRENT_ADDR, 4) + # 使用 struct.unpack 将 4 字节解析为单精度浮点数 + # ' int: + """ + 读取模块的 8 位模拟输入值(ADC 采样值)。 + 可用于读取外接的模拟传感器(如电位器、光敏电阻等)。 + + 返回: + 整数(0~255),代表 8 位 ADC 的采样值 + """ + # 通过 I2C 从 8 位 ADC 寄存器 MODULE_4ENCODERMOTOR_ADC_8BIT_REG (0xA0) + # 读取 1 个字节的数据 + data = self._read_block(MODULE_4ENCODERMOTOR_ADC_8BIT_REG, 1) + # 返回读取到的第一个字节(也是唯一一个字节)作为 ADC 采样值 + return data[0] + + def set_soft_start_and_stop(self, index: int, state: bool) -> None: + """ + 启用或禁用指定电机的软启动/软停止功能。 + 软启动:电机启动时缓慢加速,而不是突然全速,可以减少冲击。 + 软停止:电机停止时缓慢减速,而不是突然刹车。 + + 实现原理:模块用一个字节(8 位)的位图来记录 4 个电机的软启停状态, + 每个位(bit)对应一个电机。我们需要先读取当前字节,然后修改对应的位, + 再写回模块。 + + 参数: + index: 电机索引(0~3) + state: True 表示启用软启停,False 表示禁用 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 第 1 步:通过 I2C 读取软启停寄存器的当前值(1 个字节) + # 先读取是避免覆盖其他电机已设置的状态 + data = self._read_block(MODULE_4ENCODERMOTOR_SOFT_START_STOP_ADDR, 1) + # 取出读取到的第 1 个字节(也是唯一字节)作为当前状态位图 + buf = data[0] + # 先清除当前电机对应的位:将对应位设为 0,其他位保持不变 + # 1 << index:将 1 左移 index 位,得到只有第 index 位为 1 的数 + # 例如 index=2 时,1<<2 = 0b00000100 + # ~(1 << index):按位取反,得到只有第 index 位为 0,其他位为 1 的数 + # buf = buf & ~(1 << index):按位与运算,将第 index 位强制设为 0 + buf = buf & ~(1 << index) + # 如果 state 为 True(启用软启停),则将对应位设为 1 + if state: + # buf | (1 << index):按位或运算,将第 index 位设为 1,其他位不变 + buf = buf | (1 << index) + # 第 2 步:通过 I2C 将修改后的字节写回软启停寄存器 + self._write_byte(MODULE_4ENCODERMOTOR_SOFT_START_STOP_ADDR, buf) + + def get_soft_start_and_stop(self, index: int) -> bool: + """ + 查询指定电机的软启动/软停止功能是否已启用。 + + 参数: + index: 电机索引(0~3) + 返回: + True 表示软启停已启用,False 表示未启用 + """ + # 将电机索引限制在 0~3 范围内 + index = _constrain_index(index) + # 通过 I2C 读取软启停寄存器的当前值(1 个字节) + data = self._read_block(MODULE_4ENCODERMOTOR_SOFT_START_STOP_ADDR, 1) + # 检查第 index 位是否为 1: + # data[0] & (1 << index):将字节与只有第 index 位为 1 的数做按位与 + # 如果结果非零,说明该位为 1(已启用),bool() 返回 True + # 如果结果为零,说明该位为 0(未启用),bool() 返回 False + return bool(data[0] & (1 << index)) diff --git a/drv_stepmotor.py b/drv_stepmotor.py new file mode 100644 index 0000000..31d61e4 --- /dev/null +++ b/drv_stepmotor.py @@ -0,0 +1,369 @@ +""" +Raspberry Pi 5 + M5Stack StepMotor Driver 模块驱动 +===================================================== + +本模块用于驱动 M5Stack StepMotor Driver 模块,控制最多三个步进电机, +驱动圆柱在水槽中直线移动。 + +本模块结合了两种控制方式: +1. I2C 通信(地址 0x27):用于控制模块的使能/复位/配置 +2. 硬件 PWM(GPIO12):用于产生步进脉冲,通过调整脉冲频率控制电机速度 + +方向控制通过独立的 GPIO 引脚(5/6/16)实现,三个电机共享同一路 PWM +但方向可以独立控制。 + +PWM 概念简介: + PWM(Pulse Width Modulation,脉冲宽度调制)是一种通过快速开关信号来 + 模拟不同电压输出的技术。在步进电机控制中,PWM 的频率决定电机转速—— + 频率越高,脉冲越快,电机转动越快。 + +参考文档: + https://docs.m5stack.com/en/module/Stepmotor%20Driver%20Module13.2%20v1.1 + +依赖库:smbus2(I2C 通信库) + +使用示例: + 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,初始 1Hz,PWM 芯片 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 置 0):0xEF = 0b11101111,bit 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 控制电机 0,bit 1 控制电机 1,bit 2 控制电机 2。 + 将某位置 1 表示复位该电机,置 0 表示释放。 + + 参数: + resmtr: 电机索引(0、1、2),分别对应三个步进电机通道 + enable: True 表示复位(将对应位设为 1),False 表示释放(将对应位设为 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< 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(π) = -1,s = (-1+1)/2 × speed = 0 × speed = 0(起始速度为 0) + - i=50 时:cos(π×(1-0.5)) = cos(π/2) = 0,s = (0+1)/2 × speed = 0.5 × speed(中间速度为目标一半) + - i=99 时:cos(π×(1-0.99)) = cos(0.01π) ≈ 1,s = (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) = 1,s = (1+1)/2 × speed = 1 × speed(保持当前速度开始减速) + - i=50 时:cos(π/2) = 0,s = (0+1)/2 × speed = 0.5 × speed(速度降到一半) + - i=99 时:cos(0.99π) ≈ -1,s = (-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 时:因子=1,s = 1×delta + speed_m_s = current_speed(保持当前速度) + - i=99 时:因子≈0,s = 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() 清理所有资源,确保即使发生异常也能释放硬件 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d66e53c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,28 @@ +# ================================================== +# 圆柱水槽运动控制系统 — Python 依赖列表 +# 安装命令:pip install -r requirements.txt +# ================================================== + +# SPI 通信库 +# 用于树莓派通过 SPI 总线与 ADS124S08 ADC 芯片通信 +# 实现 24 位高精度模数转换数据的读取 +spidev + +# I2C 通信库 +# 用于树莓派通过 I2C 总线与以下模块通信: +# - M5Stack 4EncoderMotor 模块(地址 0x24) +# - M5Stack StepMotor Driver 模块(地址 0x27) +smbus2 + +# 树莓派硬件 PWM 控制库 +# 用于通过树莓派硬件 PWM 发生器(GPIO12)产生步进脉冲 +# 驱动步进电机按指定速度运动 +rpi_hardware_pwm + +# Jupyter Notebook 环境 +# 用于交互式运行 test.ipynb 教学演示 +jupyter + +# 数据可视化库(可选) +# test.ipynb 中的绘图功能依赖此库 +matplotlib