Управление электромотором с помощью джойстика в PyQt5 — это комплексная задача, требующая интеграции GUI, обработки ввода и взаимодействия с аппаратным обеспечением. Рассмотрю подробно все аспекты реализации.
1. Архитектура системы
Система состоит из трех основных компонентов:
- GUI на PyQt5 — интерфейс с виртуальным джойстиком
- Логика управления — обработка позиции джойстика и преобразование в команды
- Аппаратный слой — взаимодействие с электромотором через GPIO, Serial или другую периферию
2. Создание виртуального джойстика в PyQt5
Базовый класс джойстика:
from PyQt5.QtWidgets import QWidget, QApplication, QVBoxLayout, QLabel from PyQt5.QtCore import Qt, QPoint, pyqtSignal from PyQt5.QtGui import QPainter, QColor, QPen import math import sys class JoystickWidget(QWidget): # Сигналы для передачи координат positionChanged = pyqtSignal(float, float) # x, y в диапазоне [-1, 1] motorSpeedChanged = pyqtSignal(float, float) # скорость для двух моторов def __init__(self, parent=None): super().__init__(parent) self.setMinimumSize(200, 200) self.margin = 10 self.center = QPoint() self.outer_radius = 0 self.inner_radius = 20 self.relative_position = QPoint() self.is_pressed = False def resizeEvent(self, event): self.center = QPoint(self.width() // 2, self.height() // 2) self.outer_radius = min(self.width(), self.height()) // 2 - self.margin self.calculate_relative_position() def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # Рисуем внешний круг painter.setPen(QPen(QColor(100, 100, 100), 2)) painter.setBrush(QColor(200, 200, 200)) painter.drawEllipse(self.center, self.outer_radius, self.outer_radius) # Рисуем внутренний джойстик joystick_pos = self.center + self.relative_position painter.setPen(QPen(QColor(50, 50, 50), 2)) painter.setBrush(QColor(100, 150, 200)) painter.drawEllipse(joystick_pos, self.inner_radius, self.inner_radius) def calculate_relative_position(self): if not self.is_pressed: self.relative_position = QPoint(0, 0) else: # Ограничиваем движение внутри внешнего круга max_distance = self.outer_radius - self.inner_radius distance = math.sqrt(self.relative_position.x()**2 + self.relative_position.y()**2) if distance > max_distance: scale = max_distance / distance self.relative_position = QPoint( int(self.relative_position.x() * scale), int(self.relative_position.y() * scale) ) def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.is_pressed = True self.update_position(event.pos()) def mouseMoveEvent(self, event): if self.is_pressed: self.update_position(event.pos()) def mouseReleaseEvent(self, event): if event.button() == Qt.LeftButton: self.is_pressed = False self.relative_position = QPoint(0, 0) self.update() self.emit_position() def update_position(self, pos): self.relative_position = pos - self.center self.calculate_relative_position() self.update() self.emit_position() def emit_position(self): if self.outer_radius > 0: # Нормализуем координаты в диапазон [-1, 1] max_distance = self.outer_radius - self.inner_radius x = self.relative_position.x() / max_distance if max_distance > 0 else 0 y = -self.relative_position.y() / max_distance if max_distance > 0 else 0 # Ограничиваем значения x = max(-1.0, min(1.0, x)) y = max(-1.0, min(1.0, y)) self.positionChanged.emit(x, y) self.calculate_motor_speeds(x, y) def calculate_motor_speeds(self, x, y): """ Преобразуем координаты джойстика в скорости для двух моторов Используем дифференциальное управление для робота/транспорта """ # Нормализуем вектор magnitude = math.sqrt(x*x + y*y) if magnitude > 1.0: x /= magnitude y /= magnitude # Вычисляем скорости для левого и правого мотора # Формула для дифференциального привода: left_speed = y + x # Движение вперед + поворот right_speed = y - x # Движение вперед - поворот # Нормализуем скорости в диапазон [-1, 1] max_speed = max(abs(left_speed), abs(right_speed), 1.0) left_speed /= max_speed right_speed /= max_speed self.motorSpeedChanged.emit(left_speed, right_speed)
3. Главное окно приложения
from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QTextEdit) from PyQt5.QtCore import QTimer class MotorControlWindow(QMainWindow): def __init__(self): super().__init__() self.init_ui() self.init_motor_controller() def init_ui(self): self.setWindowTitle("Управление электромотором через джойстик") self.setGeometry(100, 100, 800, 600) central_widget = QWidget() self.setCentralWidget(central_widget) layout = QHBoxLayout(central_widget) # Панель джойстика joystick_group = QGroupBox("Виртуальный джойстик") joystick_layout = QVBoxLayout() self.joystick = JoystickWidget() joystick_layout.addWidget(self.joystick) joystick_group.setLayout(joystick_layout) # Панель информации info_group = QGroupBox("Информация и управление") info_layout = QVBoxLayout() self.coordinates_label = QLabel("Координаты: X: 0.00, Y: 0.00") self.speed_label = QLabel("Скорости моторов: Левый: 0.00, Правый: 0.00") self.status_label = QLabel("Статус: Не подключено") self.log_text = QTextEdit() self.log_text.setMaximumHeight(200) info_layout.addWidget(self.coordinates_label) info_layout.addWidget(self.speed_label) info_layout.addWidget(self.status_label) info_layout.addWidget(QLabel("Лог:")) info_layout.addWidget(self.log_text) info_group.setLayout(info_layout) layout.addWidget(joystick_group, 1) layout.addWidget(info_group, 1) # Подключаем сигналы self.joystick.positionChanged.connect(self.on_position_changed) self.joystick.motorSpeedChanged.connect(self.on_motor_speed_changed) def init_motor_controller(self): """Инициализация контроллера моторов""" self.motor_controller = None # Здесь будет инициализация конкретного аппаратного контроллера def on_position_changed(self, x, y): self.coordinates_label.setText(f"Координаты: X: {x:.2f}, Y: {y:.2f}") self.log_message(f"Джойстик: X={x:.2f}, Y={y:.2f}") def on_motor_speed_changed(self, left_speed, right_speed): self.speed_label.setText( f"Скорости моторов: Левый: {left_speed:.2f}, Правый: {right_speed:.2f}" ) # Отправляем команды моторам self.send_motor_commands(left_speed, right_speed) def send_motor_commands(self, left_speed, right_speed): """Отправка команд на реальные моторы""" if self.motor_controller: try: # Преобразуем нормализованные скорости в реальные значения left_pwm = int(left_speed * 255) # для ШИМ 0-255 right_pwm = int(right_speed * 255) self.motor_controller.set_motor_speeds(left_pwm, right_pwm) self.status_label.setText("Статус: Управление активно") except Exception as e: self.status_label.setText(f"Статус: Ошибка - {str(e)}") self.log_message(f"Ошибка управления: {str(e)}") else: self.log_message(f"Симуляция: Левый мотор: {left_speed:.2f}, Правый: {right_speed:.2f}") def log_message(self, message): self.log_text.append(f"[{QTimer().currentTime().toString()}] {message}")
4. Реализация аппаратного контроллера
Для Raspberry Pi (используя RPi.GPIO):
import RPi.GPIO as GPIO import time class DCMotorController: def __init__(self): # Настройка пинов (замените на свои) self.LEFT_MOTOR_PWM = 18 self.LEFT_MOTOR_IN1 = 23 self.LEFT_MOTOR_IN2 = 24 self.RIGHT_MOTOR_PWM = 19 self.RIGHT_MOTOR_IN1 = 25 self.RIGHT_MOTOR_IN2 = 26 self.setup_gpio() def setup_gpio(self): GPIO.setmode(GPIO.BCM) GPIO.setwarnings(False) # Настройка пинов для левого мотора GPIO.setup(self.LEFT_MOTOR_PWM, GPIO.OUT) GPIO.setup(self.LEFT_MOTOR_IN1, GPIO.OUT) GPIO.setup(self.LEFT_MOTOR_IN2, GPIO.OUT) # Настройка пинов для правого мотора GPIO.setup(self.RIGHT_MOTOR_PWM, GPIO.OUT) GPIO.setup(self.RIGHT_MOTOR_IN1, GPIO.OUT) GPIO.setup(self.RIGHT_MOTOR_IN2, GPIO.OUT) # Инициализация ШИМ self.left_pwm = GPIO.PWM(self.LEFT_MOTOR_PWM, 1000) # 1 kHz self.right_pwm = GPIO.PWM(self.RIGHT_MOTOR_PWM, 1000) self.left_pwm.start(0) self.right_pwm.start(0) def set_motor_speeds(self, left_speed, right_speed): """Установка скорости моторов (-255 до 255)""" self.set_motor_speed('left', left_speed) self.set_motor_speed('right', right_speed) def set_motor_speed(self, motor, speed): """Управление одним мотором""" if motor == 'left': pwm = self.left_pwm in1 = self.LEFT_MOTOR_IN1 in2 = self.LEFT_MOTOR_IN2 else: pwm = self.right_pwm in1 = self.RIGHT_MOTOR_IN1 in2 = self.RIGHT_MOTOR_IN2 speed = max(-255, min(255, speed)) duty_cycle = abs(speed) if speed > 0: # Вперед GPIO.output(in1, GPIO.HIGH) GPIO.output(in2, GPIO.LOW) elif speed < 0: # Назад GPIO.output(in1, GPIO.LOW) GPIO.output(in2, GPIO.HIGH) else: # Стоп GPIO.output(in1, GPIO.LOW) GPIO.output(in2, GPIO.LOW) pwm.ChangeDutyCycle(duty_cycle * 100 / 255) def cleanup(self): self.left_pwm.stop() self.right_pwm.stop() GPIO.cleanup()
Для Arduino через Serial:
import serial import time class ArduinoMotorController: def __init__(self, port='/dev/ttyUSB0', baudrate=9600): self.serial = None try: self.serial = serial.Serial(port, baudrate, timeout=1) time.sleep(2) # Ожидание инициализации Arduino except Exception as e: print(f"Ошибка подключения к Arduino: {e}") def set_motor_speeds(self, left_speed, right_speed): if self.serial and self.serial.is_open: # Преобразуем в диапазон 0-255 для Arduino left_pwm = int((left_speed + 1) * 127.5) right_pwm = int((right_speed + 1) * 127.5) command = f"M{left_pwm:03d}{right_pwm:03d}n" self.serial.write(command.encode()) def close(self): if self.serial: self.serial.close()
5. Главная функция приложения
def main(): app = QApplication(sys.argv) # Создаем и показываем главное окно window = MotorControlWindow() window.show() # Инициализируем контроллер моторов (раскомментируйте нужный) # Для Raspberry Pi: # try: # window.motor_controller = DCMotorController() # window.status_label.setText("Статус: Подключено к GPIO") # except Exception as e: # window.status_label.setText(f"Статус: GPIO недоступно - {e}") # Для Arduino: # window.motor_controller = ArduinoMotorController() # Запускаем приложение exit_code = app.exec_() # Корректное завершение if window.motor_controller: if hasattr(window.motor_controller, 'cleanup'): window.motor_controller.cleanup() elif hasattr(window.motor_controller, 'close'): window.motor_controller.close() sys.exit(exit_code) if __name__ == "__main__": main()
6. Дополнительные улучшения
Калибровка и мертвые зоны:
class AdvancedJoystickWidget(JoystickWidget): def __init__(self, parent=None): super().__init__(parent) self.dead_zone = 0.1 # Мертвая зона 10% self.sensitivity = 1.0 def calculate_motor_speeds(self, x, y): # Применяем мертвую зону if abs(x) < self.dead_zone and abs(y) < self.dead_zone: self.motorSpeedChanged.emit(0, 0) return # Применяем чувствительность x = self.apply_sensitivity(x) y = self.apply_sensitivity(y) # Остальная логика как раньше... super().calculate_motor_speeds(x, y) def apply_sensitivity(self, value): if value > 0: return (self.dead_zone + (1 - self.dead_zone) * ((value - self.dead_zone) / (1 - self.dead_zone)) ** self.sensitivity) else: return -(self.dead_zone + (1 - self.dead_zone) * ((abs(value) - self.dead_zone) / (1 - self.dead_zone)) ** self.sensitivity)
Безопасность и ограничения:
class SafeMotorController: def __init__(self): self.max_acceleration = 0.1 # Максимальное ускорение за шаг self.current_left_speed = 0 self.current_right_speed = 0 def set_motor_speeds(self, target_left, target_right): # Плавное изменение скорости self.current_left_speed = self.smooth_transition( self.current_left_speed, target_left ) self.current_right_speed = self.smooth_transition( self.current_right_speed, target_right ) # Отправка команд на аппаратуру self.send_commands_to_hardware() def smooth_transition(self, current, target): difference = target - current if abs(difference) > self.max_acceleration: return current + math.copysign(self.max_acceleration, difference) return target
7. Рекомендации по использованию
- Тестирование: Всегда тестируйте без подключения моторов сначала в симуляции
- Безопасность: Добавьте