Как управлять электромотором джойстиком на PyQt 5?

Управление электромотором с помощью джойстика в 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. Рекомендации по использованию

  1. Тестирование: Всегда тестируйте без подключения моторов сначала в симуляции
  2. Безопасность: Добавьте