本项目实现了一个舵机控制系统,包含以下功能:
- 硬件:ESP32C5 开发板控制舵机(信号引脚接 GPIO2)。
- 软件:
- ESP32C5 固件:通过串口接收角度指令(0-180°),控制舵机旋转。
- PyQt5 上位机:提供图形化界面,包含串口选择、角度滑块控制、状态显示和舵机动画模拟。
- 通信:PC 与 ESP32C5 通过 USB 串口通信,波特率 115200,发送格式为 角度值\n(如 90\n)。
- 硬件图如下:
所需硬件
- ESP32C5 开发板(支持 ESP-IDF 3.x API)。
- SG90 或类似舵机(工作电压 4.8-6V,脉宽 0.5ms-2.5ms)。
- USB 数据线(用于串口通信和烧录固件)。
- 外部电源(推荐 5V 电源模块,舵机供电)。
- 杜邦线若干。
- 电脑(运行 PyQt5 上位机,Windows/Linux/macOS 均可)。
接线说明
舵机引脚 | ESP32C5 引脚 | 说明 |
---|---|---|
信号线(橙色) | GPIO2 | PWM 信号控制舵机角度 |
电源线(红色) | 5V | ESP32C5 的 5V 引脚 |
地线(棕色) | GND | 连接 ESP32C5 和外部电源的 GND |
ESP32C5端完整代码如下:
#define SERVO_PIN 2 // 舵机信号引脚
#define PWM_FREQ 50 // 50Hz (20ms周期)
#define PWM_RES 10 // 10位分辨率 (0-1023)
void setup() {
Serial.begin(115200);
while (!Serial);
Serial.println("ESP32C5 Servo Control via Serial (Core 3.x API)");
Serial.println("Enter angle (0-180 degrees):");
// 新API (3.x):绑定引脚、频率、分辨率(自动分配通道,返回bool)
if (!ledcAttach(SERVO_PIN, PWM_FREQ, PWM_RES)) {
Serial.println("Error: Failed to attach PWM to pin");
while (true); // 停止程序,如果绑定失败
}
// 初始化舵机到0度
setServoAngle(0);
}
void loop() {
// 检查串口是否有数据
if (Serial.available() > 0) {
String input = Serial.readStringUntil('\n'); // 读取一行输入
input.trim(); // 去除首尾空格
int angle = input.toInt(); // 转换为整数
// 验证角度范围
if (angle >= 0 && angle <= 180) {
setServoAngle(angle);
Serial.print("Servo set to ");
Serial.print(angle);
Serial.println(" degrees");
} else {
Serial.println("Error: Angle must be between 0 and 180");
}
}
}
// 设置舵机角度
void setServoAngle(int angle) {
// 舵机PWM脉宽:0.5ms (0度) ~ 2.5ms (180度),周期20ms (50Hz)
// 计算占空比:(pulse_width_ms / 20ms) * (2^res - 1)
// 0.5ms -> 25, 2.5ms -> 128 (10位分辨率)
int duty = map(angle, 0, 180, 25, 128); // 映射角度到PWM占空比值
ledcWrite(SERVO_PIN, duty); // 3.x中使用ledcWrite(pin, duty)直接写占空比(通道自动)
}
基于Arduino框架,通过串口接收PC发送的角度指令(0-180°)来驱动连接在GPIO2的舵机,采用50Hz PWM信号(20ms周期,10位分辨率)。代码在setup中初始化串口(波特率115200)并使用ESP32 Core 3.x API的ledcAttach配置PWM,初始设置舵机角度为0°。在loop中,程序循环读取串口输入,解析为整数角度,验证范围后通过setServoAngle函数映射角度到PWM占空比(0.5ms-2.5ms对应25-128),并使用ledcWrite输出信号控制舵机,同时通过串口反馈执行状态。
Python端代码则通过PyQt5构建界面:分为左侧控制区(串口选择、连接按钮、角度滑块、状态显示)和右侧动画区(模拟舵机主体和臂的旋转)。通过pyserial实现与ESP32C5的串口通信(波特率115200,发送格式为角度值\n),支持动态更新串口列表、连接/断开串口、实时发送角度指令。滑块控制角度(0-180°)时,界面同步更新角度显示并通过QTransform实现舵机臂顺时针旋转动画,关闭窗口时自动断开串口,完整Python代码如下:
import sys
import serial
import serial.tools.list_ports
from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QSlider, QLabel, QPushButton, QComboBox, QGraphicsView,
QGraphicsScene, QGraphicsRectItem)
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont, QTransform, QPen
import qdarkstyle # 深色主题(需安装:pip install qdarkstyle)
class ServoControlUI(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("ESP32C5 舵机控制")
self.setGeometry(100, 100, 800, 600)
# 初始化串口
self.serial_port = None
self.baudrate = 115200 # 与ESP32C5代码匹配
# 设置机器人风格(深色主题)
self.setStyleSheet(qdarkstyle.load_stylesheet_pyqt5())
self.init_ui()
def init_ui(self):
# 主窗口布局
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
# 左侧:串口控制 + 滑块
left_widget = QWidget()
left_layout = QVBoxLayout(left_widget)
left_layout.setAlignment(Qt.AlignTop)
# 标题
title_label = QLabel("舵机控制系统")
title_label.setFont(QFont("微软雅黑", 16, QFont.Bold)) # 中文字体
title_label.setAlignment(Qt.AlignCenter)
left_layout.addWidget(title_label)
# 串口选择
serial_layout = QHBoxLayout()
self.port_combo = QComboBox()
self.port_combo.setStyleSheet("QComboBox { padding: 5px; border-radius: 5px; }")
serial_layout.addWidget(QLabel("串口:"))
serial_layout.addWidget(self.port_combo)
self.connect_button = QPushButton("连接")
self.connect_button.setStyleSheet("""
QPushButton { background-color: #2ecc71; color: white; padding: 8px; border-radius: 5px; font-weight: bold; }
QPushButton:hover { background-color: #27ae60; }
QPushButton:disabled { background-color: #7f8c8d; }
QPushButton:pressed { background-color: #1e8449; }
""")
self.connect_button.clicked.connect(self.toggle_serial)
serial_layout.addWidget(self.connect_button)
# 初始化端口列表(在 connect_button 定义后再调用)
self.update_ports()
left_layout.addLayout(serial_layout)
# 滑块控制
self.slider = QSlider(Qt.Horizontal)
self.slider.setMinimum(0)
self.slider.setMaximum(180)
self.slider.setValue(0)
self.slider.setTickPosition(QSlider.TicksBelow)
self.slider.setTickInterval(10)
self.slider.setStyleSheet("""
QSlider::groove:horizontal {
height: 8px;
background: #34495e;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #e74c3c;
width: 20px;
height: 20px;
border-radius: 10px;
}
""")
self.slider.valueChanged.connect(self.update_servo)
left_layout.addWidget(QLabel("舵机角度控制 (0-180°):"))
left_layout.addWidget(self.slider)
# 角度显示
self.angle_label = QLabel("角度: 0°")
self.angle_label.setFont(QFont("微软雅黑", 12, QFont.Bold))
left_layout.addWidget(self.angle_label)
# 状态显示
self.status_label = QLabel("状态: 未连接")
self.status_label.setFont(QFont("微软雅黑", 10))
self.status_label.setStyleSheet("color: #e74c3c;") # 红色状态提示
left_layout.addWidget(self.status_label)
# 添加伸缩项
left_layout.addStretch()
main_layout.addWidget(left_widget)
# 右侧:零件动画区域
animation_widget = QWidget()
animation_layout = QVBoxLayout(animation_widget)
anim_title = QLabel("舵机动画模拟")
anim_title.setFont(QFont("微软雅黑", 14, QFont.Bold))
anim_title.setAlignment(Qt.AlignCenter)
animation_layout.addWidget(anim_title)
self.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene)
self.view.setMinimumSize(400, 400)
self.view.setStyleSheet("border: 2px solid #34495e; background: #2c3e50; border-radius: 5px;")
# 绘制舵机主体(矩形模拟圆形)
servo_body = QGraphicsRectItem(-30, -30, 60, 60)
servo_body.setBrush(Qt.gray)
pen = QPen(Qt.NoPen)
servo_body.setPen(pen)
self.scene.addItem(servo_body)
# 绘制舵机臂(矩形表示)
self.servo_arm = QGraphicsRectItem(-5, -60, 10, 120) # 长条形臂
self.servo_arm.setBrush(Qt.red)
arm_pen = QPen(Qt.black)
self.servo_arm.setPen(arm_pen)
self.servo_arm.setTransformOriginPoint(0, 60) # 设置旋转中心在臂底部(匹配主体)
self.scene.addItem(self.servo_arm)
# 添加中心点指示
center_point = QGraphicsRectItem(-2, 58, 4, 4)
center_point.setBrush(Qt.yellow)
self.scene.addItem(center_point)
animation_layout.addWidget(self.view)
main_layout.addWidget(animation_widget)
def update_ports(self):
"""更新可用串口列表"""
self.port_combo.clear()
ports = serial.tools.list_ports.comports()
for port in ports:
port_info = f"{port.device} - {port.description}"
self.port_combo.addItem(port_info, port.device) # 存储实际端口
if not ports:
self.port_combo.addItem("无可用串口")
self.connect_button.setEnabled(False)
else:
self.connect_button.setEnabled(True)
def toggle_serial(self):
"""连接/断开串口"""
if self.serial_port and self.serial_port.is_open:
# 断开连接
self.serial_port.close()
self.serial_port = None
self.connect_button.setText("连接")
self.status_label.setText("状态: 已断开连接")
self.status_label.setStyleSheet("color: #e74c3c;")
self.slider.setEnabled(False)
else:
# 尝试连接
try:
port_data = self.port_combo.currentData()
if port_data is None:
port = self.port_combo.currentText()
if "无可用串口" in port:
self.status_label.setText("状态: 无可用串口")
return
else:
port = port_data
self.serial_port = serial.Serial(port, self.baudrate, timeout=1)
self.connect_button.setText("断开")
self.status_label.setText("状态: 已连接")
self.status_label.setStyleSheet("color: #2ecc71;")
self.slider.setEnabled(True)
# 初始化舵机到0度
self.send_angle(0)
except serial.SerialException as e:
self.status_label.setText(f"状态: 连接失败 - {str(e)}")
self.status_label.setStyleSheet("color: #e74c3c;")
def send_angle(self, angle):
"""发送角度到ESP32C5"""
if self.serial_port and self.serial_port.is_open:
try:
# 发送格式:纯数字 + 换行符(如"90\n")
self.serial_port.write(f"{angle}\n".encode())
print(f"发送角度: {angle}°") # 调试信息
except serial.SerialException as e:
self.status_label.setText(f"状态: 串口错误 - {str(e)}")
self.status_label.setStyleSheet("color: #e74c3c;")
self.serial_port.close()
self.serial_port = None
self.connect_button.setText("连接")
self.slider.setEnabled(False)
def update_servo(self):
"""更新舵机角度和动画"""
angle = self.slider.value()
self.angle_label.setText(f"角度: {angle}°")
# 更新动画:旋转舵机臂(取反角度以改变旋转方向)
transform = QTransform()
transform.rotate(-angle) # 改为顺时针旋转
self.servo_arm.setTransform(transform)
self.scene.update()
# 发送到ESP32C5
self.send_angle(angle)
def closeEvent(self, event):
"""关闭窗口时断开串口"""
if self.serial_port and self.serial_port.is_open:
self.serial_port.close()
event.accept()
if __name__ == "__main__":
app = QApplication(sys.argv)
window = ServoControlUI()
window.show()
sys.exit(app.exec_())
界面样式如下图所示:

完整演示视频如下:
评论