В предыдущей статье я показал, как настроить GPIO одноплатника на примере Orange Pi Zero H+. Я привел команды для проверки GPIO и написал скрипт gpio_setup.sh для добавления необходимых прав на GPIO для пользователя. Также разработал класс LedLineGpio для управления светодиодами и настроил задержку при отправке команд. Кроме того, я изменил механизм их отправки так, чтобы команда не дублировалась при удержании кнопки.

В четвёртой статье я расскажу, как управлять моторами через популярный драйвер двигателей L298N. Также покажу, как подключить этот драйвер к одноплатнику Orange Pi Zero H+. Будет представлен программный код для управления моторами через GPIO, а также код самих команд управления роботом для бэкенд-приложения на FastAPI.

Статья будет полезна любителям DIY-проектов и веб-разработчикам, интересующимся фреймворком FastAPI.


Драйвер двигателей L298N

Драйвер двигателей — это электронное устройство, которое позволяет микроконтроллеру управлять скоростью и направлением вращения электродвигателя, подавая на него соответствующие сигналы.

Изначально я планировал использовать российский драйвер ЛМ2-130, который применял в статье, где собирал гусеничную платформу на Arduino. Однако на практике столкнулся с ограничением моей платы Orange Pi Zero H+: при работе с ЛМ2-130 она позволяет управлять только одним мотором.

Причина в том, что у Orange Pi Zero H+ есть лишь один PWM-пин, необходимый для регулирования скорости. Управление скоростью через PWM поддерживается во всех драйверах моторов, но у ЛМ2-130 он также отвечает за остановку двигателя.

Как управлять мотором на ЛМ2-130 (на базе микросхемы L293D)?

  • Пины IN Right и IN Left — подача 0 (LOW/False) или 1 (HIGH/True) для смены направления вращения мотора;

  • Пины PWM-Right и PWM-Left — значение от 0 до 255 для регулирования мощности вращения.

Таким образом, без второго PWM-пина невозможно корректно останавливать второй мотор — а для гусеничной платформы с двумя двигателями это критично. К тому же, если на моей плате есть только один PWM-вывод, не исключено, что на других одноплатниках может возникнуть та же проблема.

Поэтому мне потребовался драйвер, который умеет отключать моторы без использования PWM. В итоге я выбрал более простой и популярный L298N — он позволяет управлять моторами без необходимости выделять под это отдельные PWM-пины.

Схема L298N
Схема L298N

Управление моторами на L298N:

  • Пины ENA и ENB:
    Значения от 0 до 255 — регулировка мощности (для этого нужно снять перемычку);
    Если оставить перемычку на месте, моторы будут вращаться на максимальной скорости.

  • Пины IN1 и IN2 — управление направлением вращения левого мотора:
    IN1 = HIGH и IN2 = LOW — мотор вращается в одну сторону;
    IN1 = LOW и IN2 = HIGH — мотор вращается в другую сторону;
    N1 = LOW и IN2 = LOW — мотор останавливается.

  • Пины IN3 и IN4 — управление направлением вращения правого мотора (по аналогии с левым).

Таким образом, для каждого направления используется свой управляющий пин, а при подаче LOW на оба пина мотор останавливается без применения PWM. Теперь подключим драйвер к одноплатнику.

Подключение L298N к Orange Pi Zero H+:

  • IN1 -> Pa11;

  • IN2 -> Pa12;

  • IN3 -> Pa18;

  • IN4 -> Pa19;

  • +5V вход -> +5V;

  • GND -> GND.

Драйвер я запитал напрямую от одноплатника — исключительно для демонстрации на тестовом стенде. Плата не способна отдать через пин 5 В ток, достаточный для раскрытия всего потенциала DC-моторов и самого драйвера. Поэтому моторы не раскручиваются на полную, но это даже удобно: стенд не будет слишком быстро «уезжать» со стола.

Кроме того, мой небольшой powerbank выдаёт лишь 1 A на выходе, чего тоже недостаточно. В дальнейшем я планирую заменить его на блок питания минимум на 2 A. С более мощным источником питания система станет стабильнее, а платформа будет быстрее двигаться и получит лучшую тягу.

Схема в подключенном виде
Схема в подключенном виде

На фото — тестовый стенд с двумя LEGO-моторами. Подключение выполнено через самодельный адаптер LEGO → Dupont мама, собранный буквально на коленке. Такая конструкция позволяет проверить правильность работы моторов без подключения громоздкого гусеничного шасси.


Замена статического ip на динамический

Статический IP-адрес на первый взгляд кажется удобным решением. Устройству задаётся фиксированный адрес, по которому можно подключаться без необходимости проверять его актуальность в настройках роутера.

Однако у такого подхода есть подводный камень: статический IP может пересечься с динамическим адресом другого устройства. Именно в такой ситуации я недавно оказался: IP моего ПК неожиданно совпал со статическим IP одноплатника, который я включил позже.

Что делать в подобных случаях? Можно перезапустить ПК или вручную переназначить ему IP, но это неудобно и отнимает время. Поэтому я решил отказаться от статического адреса на одноплатнике и вместо этого задать ему имя (доменоподобный идентификатор), связанный с его динамическим IP. Это имя будет видно всем устройствам локальной сети.

В поиске решения я наткнулся на avahi-daemon — простую и элегантную службу. Она реализует протокол mDNS/DNS-SD (мультикаст DNS), который позволяет устройствам обнаруживать друг друга в локальной сети без централизованного DNS-сервера. С его помощью можно автоматически находить сервисы (например, принтеры, медиа-серверы, SSH-хосты) по адресу вида hostname.local.

Устанавливаю avahi-daemon:

sudo apt install avahi-daemon

Задаю имя хоста для одноплатника:

sudo hostnamectl set-hostname mydevice

Разбор команды:

  • hostnamectl — утилита управления именем хоста (systemd);

  • set-hostname — подкоманда для установки нового имени;

  • mydevice — имя хоста, которое сохранится в системе.

Перезапуск службы avahi-daemon для применения настроек:

sudo systemctl restart avahi-daemon

Теперь к одноплатнику можно обращаться по адресу mydevice.local с любого устройства в сети. При этом IP останется динамическим, а конфликтов с адресами больше не возникнет.

При необходимости avahi-daemon можно установить и на другие Linux-устройства, чтобы обращаться к ним по аналогичной схеме.

Если используете avahi-daemon в приложении, не забудьте заменить в конфигурации IP-адрес устройства на его имя вида yourdevice.local.


Код робота

Начну с классов, отвечающих за управление GPIO-линиями, которые находятся в файле gpio_control.py.

Я слегка модифицировал класс LedLineGpio, сделав его универсальным. Теперь параметры линии GPIO передаются в конструктор, а не захардкожены.

Новый импорт:

from settings import settings

Обновлённый код класса LedLineGpio:

class LedLineGpio:
    """Класс для управления линией gpio для LED"""
    
    def __init__(self, line: int, gpio_path: str, consumer: str) -> None:
        self.line = line
        self._request = request_lines(
            path=gpio_path,
            consumer=consumer,
            config={
                self.line: LineSettings(
                    direction=Direction.OUTPUT,
                    output_value=Value.INACTIVE
                )
            }
        )
    
    # Другой код

Разбор обновлённого кода:

  • line: int — номер линии (пина) GPIO, с которой будет работать класс. Это индекс внутри контроллера GPIO;

  • gpio_path: str — путь к устройству GPIO в системе (обычно что-то вроде /dev/gpiochip0), указывает, через какой чип/контроллер вести работу;

  • consumer: str — строковое имя потребителя (идентификатор клиента), которое передаётся драйверу GPIO для отладки и отслеживания того, кто захватил линию.

Следующий шаг — написать универсальный класс для управления линиями GPIO моторов. Он позволит работать с любым количеством двигателей, создавая отдельный экземпляр класса для каждого мотора.

Класс MotorDCLineGpio:

class MotorDCLineGpio:
    """Класс для управления линией GPIO для DC мотора"""

    def __init__(self, line_in1: int, line_in2: int, gpio_path: str, consumer: str) -> None:
        self.line_in1 = line_in1
        self._request_in1 = request_lines(
            path=gpio_path,
            consumer=consumer,
            config={
                self.line_in1: LineSettings(
                    direction=Direction.OUTPUT,
                    output_value=Value.INACTIVE
                )
            }
        )

        self.line_in2 = line_in2
        self._request_in2 = request_lines(
            path=gpio_path,
            consumer=consumer,
            config={
                self.line_in2: LineSettings(
                    direction=Direction.OUTPUT,
                    output_value=Value.INACTIVE
                )
            }
        )

    def forward_motor(self) -> None:
        """Метод для вращения мотора вперёд"""

        self._request_in1.set_value(self.line_in1, Value.ACTIVE)
        self._request_in2.set_value(self.line_in2, Value.INACTIVE)

    def backward_motor(self) -> None:
        """Метод для вращения мотора назад"""

        self._request_in1.set_value(self.line_in1, Value.INACTIVE)
        self._request_in2.set_value(self.line_in2, Value.ACTIVE)

    def stop_motor(self) -> None:
        """Метод для остановки мотора"""

        self._request_in1.set_value(self.line_in1, Value.INACTIVE)
        self._request_in2.set_value(self.line_in2, Value.INACTIVE)

    def close(self) -> None:
        """Метод для освобождения ресурса"""

        self._request_in1.release()
        self._request_in2.release()

Разбор кода:

  • self.line_in1 и self.line_in2 — номера GPIO-линий (пинов) на одноплатнике;

  • self._request_in1 и self._request_in2 — объекты, управляющие GPIO-линиями, соответствующими пинам направления;

  • forward_motor() — вращение мотора вперёд;

  • backward_motor() — вращение мотора назад;

  • stop_motor() — остановка мотора;

  • close() — освобождение ресурсов.

Принцип работы аналогичен управлению LED: линии GPIO активируются и деактивируются через метод set_value(). Отличие в том, что для вращения мотора одна линия должна быть в состоянии Value.ACTIVE, а вторая — Value.INACTIVE. Для остановки же обе линии переводятся в Value.INACTIVE.

После написания этого класса его уже можно использовать в сервисе робота. Но есть несколько неудобств:

  1. Нужно создавать два экземпляра класса для каждого мотора и передавать в них параметры линий;

  2. Для управления одним мотором приходится вызывать сразу два метода;

  3. Такой способ не масштабируется — он неудобен для платформ с четырьмя и более моторами.

Чтобы решить эти проблемы, я написал класс-«интерфейс» для управления роботом. Он берёт на себя создание экземпляров классов для моторов, настройку GPIO и вызов нескольких методов внутри одного. В результате достаточно создать всего один экземпляр этого интерфейса в сервисе робота — и управление будет готово.

Класс RobotControl:

class RobotControl:
    """Класс для управления роботом"""
    
    def __init__(self) -> None:
        self._left_motor = MotorDCLineGpio(
            line_in1=settings.gpio_lines.left_motor_line_in1,
            line_in2=settings.gpio_lines.left_motor_line_in2,
            gpio_path=settings.gpio_lines.gpio_path,
            consumer=settings.gpio_lines.left_motor_consumer
        )
        
        self._right_motor = MotorDCLineGpio(
            line_in1=settings.gpio_lines.right_motor_line_in1,
            line_in2=settings.gpio_lines.right_motor_line_in2,
            gpio_path=settings.gpio_lines.gpio_path,
            consumer=settings.gpio_lines.right_motor_consumer
        )

        self.stop()

    def forward(self) -> None:
        """Движение робота вперёд"""
        
        self._left_motor.forward_motor()
        self._right_motor.forward_motor()        

    def backward(self) -> None:
        """Движение робота назад"""
        
        self._left_motor.backward_motor()
        self._right_motor.backward_motor()

    def left(self) -> None:
        """Поворот робота налево"""

        self._right_motor.forward_motor()
        self._left_motor.backward_motor()

    def right(self) -> None:
        """Поворот робота направо"""

        self._right_motor.backward_motor()
        self._left_motor.forward_motor()

    def stop(self) -> None:
        """Остановка робота"""

        self._left_motor.stop_motor()
        self._right_motor.stop_motor()

    def close(self) -> None:
        """Освобождение ресурса"""
        
        self._left_motor.close()
        self._right_motor.close()

Разбор кода:

  • self._left_motor и self._right_motor — атрибуты класса с экземплярами классов для двух моторов, в которые передаются настройки для GIPIO;

  • self.stop() в конструкторе — гарантирует, что все линии моторов будут выключены и при включении робота двигатели не начнут вращаться из-за случайно активированных линий.

Теперь управлять двумя моторами стало гораздо удобнее. В дальнейшем я планирую сделать класс более универсальным, чтобы можно было работать как с двухмоторными, так и с четырёхмоторными платформами.

Я создал образец .env-файла, по которому любой сможет сформировать свой конфигурационный файл с настройками.

Содержимое .env.example:

# Настройки для websocket
WEBSOCKET_HOST=хост
WEBSOCKET_PORT=порт

# Команды
FORWARD=вперёд
BACKWARD=назад
LEFT=влево
RIGHT=вправо
STOP=стоп

# Настройки для линий GPIO
GPIO_PATH=путь до gpio
LED_LINE=номер линии
LED_CONSUMER=идентификатор клиента
LEFT_MOTOR_LINE_IN1=номер линии для левого (вращение вперёд)
LEFT_MOTOR_LINE_IN2=номер линии для левого (вращение назад)
LEFT_MOTOR_CONSUMER=идентификатор клиента для левого
RIGHT_MOTOR_LINE_IN1=номер линии для правого (вращение вперёд)
RIGHT_MOTOR_LINE_IN2=номер линии для правого (вращение назад)
RIGHT_MOTOR_CONSUMER=идентификатор клиента для правого

Каждая переменная снабжена описанием. В этом примере появились новые параметры для управления линиями моторов, а также переменная для команды.

Теперь я обновляю settings.py для использования новых переменных окружения.

Обновлённый код класса CommandsRobot:

class CommandsRobot(ModelConfig):
    """Класс с командами для робота"""

    # Другой код
    stop: str

    def is_command(self, command: str) -> bool:
        """Есть ли команда в командах"""

        return command in self.model_dump().values()

Разбор нового кода:

  • stop: str — атрибут класс для хранения команды stop;

  • is_command() — метод, проверяющий наличие команды в списке доступных:
    self.model_dump().values() — собирает атрибуты класса в словарь и возвращает их значения;
    return command in self.model_dump().values() — вернёт True, если команда найдена, иначе False.

Далее пишу класс GpioLines, наследуемый от ModelConfig, который будет хранить переменные окружения, относящиеся к линиям GPIO.

Класс GpioLines:

class GpioLines(ModelConfig):
    """Класс для линий GPIO"""

    gpio_path: str
    led_line: int
    led_consumer: str
    left_motor_line_in1: int
    left_motor_line_in2: int
    left_motor_consumer: str
    right_motor_line_in1: int
    right_motor_line_in2: int
    right_motor_consumer: str

Чтобы удобно получать доступ к этим настройкам, создаю экземпляр GpioLines внутри класса Settings:

Обновлённый код класса Settings:

class Settings(ModelConfig):
    """Класс для данных конфига"""

    # Другой код
    gpio_lines: GpioLines = GpioLines()

Прежде чем реализовывать новую логику в сервисе робота, я написал класс FormResponse в файле response_data.py для формирования ответов робота веб-приложению. Идея: брать команду из запроса фронтенда и возвращать структуру ответа со статусом, сообщением и кодом ошибки (если возникнет).

Стоит помнить, что WebSocket — это не HTTP. У него (см. RFC 6455) есть собственные transport-level коды: например, 1006 означает потерю соединения.

Для обмена данными между фронтендом и бэкендом этого достаточно. Но для связи сервиса робота и бэкенда может потребоваться более детализированная обратная связь.

Когда transport-level кодов недостаточно?

  • Команда не найдена (аналог 404);

  • Команда не выполнена (например, из-за ошибки);

  • Команда успешно выполнена (аналог 200);

  • Нужен дебаг взаимодействия между сервисом робота и бэкендом.

В таких случаях удобно использовать application-level коды (200/404/500) и структуру ответа, похожую на REST. Transport-level коды описывают уровень протокола (WebSocket), а application-level — уровень приложения.

Для этого я создаю класс FormResponse, который наследуется от Enum. В Python Enum используется для описания фиксированных наборов констант. В моём случае он хранит кортежи со статус-кодами и сообщениями, что делает ответы читаемыми и структурированными.

Импорт:

from enum import Enum

Класс FromResponse:

class FormResponse(Enum):
    """Класс для формирования ответа"""

    NOT_FOUND_COMMAND = (404, 'Unknown command!')
    OK_COMMAND = (200, 'Command executed')
    SERVER_ERR = (500, 'Internal Server Error!')

    @property
    def response(self) -> dict[str, int | str]:
        """Словарь для формирования ответа серверу"""

        return {
            'status': self.value[0],
            'message': self.value[1]
        }

    def get_response_err(self, name_err: str, message_err: str) -> dict[str, int | str]:
        """Метод для формирования ответа серверу с ошибкой"""
        
        return {
            'status': self.value[0],
            'name_error': name_err,
            'message': message_err
        }

Разбор кода:

  • FormResponse — перечисление (Enum) для стандартных ответов сервера;

  • NOT_FOUND_COMMAND — статус 404, когда команда неизвестна;

  • OK_COMMAND — статус 200, команда успешно выполнена;

  • SERVER_ERR — статус 500, внутренняя ошибка сервера;

  • response — свойство, возвращающее словарь с ключами status и message для базового ответа;

  • get_response_err — метод для формирования словаря с ошибкой, включающего status, name_error и message.

Теперь всю новую логику я собираю в сервисе робота, которая находится в файле robot_pi_service.py.

Новые импорты:

from socket import gethostbyname
from json import loads, dumps, JSONDecodeError

from gpio_control import RobotControl
from response_data import FormResponse

Разбор новых импортов:

  • gethostbyname из socket — возвращает IP по имени хоста (mydevice.local);

  • dumps из json — функция для сериализации Python-объектов в JSON-строку;

  • RobotControl из gpio_control — класс для управления роботом через GPIO;

  • FormResponse из response_data — Enum с методами для формирования стандартных ответов сервера.

Обновлённый код функции start()

async def start() -> None:
    """Асинхронная функция запуска вебсокета и бесконечного цикла"""

    # Другой код
    async with serve(
        handler=robot_control_gpio, 
        host=gethostbyname(settings.websocket_host), 
        port=settings.websocket_port
    ):
        # Другой код

Разбор обновлённого кода:

  • gethostbyname(settings.websocket_host) — получает IP по имени хоста. Если передан обычный IP, возвращается он же.

Теперь WebSocket-сервис может использовать имя хоста вроде mydevice.local, которое я задал одноплатнику через hostnamectl.

Следующий шаг — подключение новой логики управления моторами в функции robot_control_gpio().

Обновлённая функция robot_control_gpio():

async def robot_control_gpio(websocket: WebSocketServerProtocol) -> None:
    """
    Асинхронная функция для управлением gpio робота (через websocket)
    """

    try:
        robot_control = RobotControl()
        commands_status = FormResponse

        while True:
            try:
                command = await asyncio.wait_for(websocket.recv(), timeout=30.0)
                data = loads(command)
                command_name = data.get('command')
            except asyncio.TimeoutError as err:
                print('Таймаут ожидания команды от клиента')
                continue  # продолжение цикла, чтобы не закрывать соединение
            except JSONDecodeError:
                print('Ошибка при декодировании JSON!')
                continue

            match command_name:
                case settings.commands_robot.forward:
                    robot_control.forward()
                case settings.commands_robot.backward:
                    robot_control.backward()
                case settings.commands_robot.left:
                    robot_control.left()
                case settings.commands_robot.right:
                    robot_control.right()
                case settings.commands_robot.stop:
                    robot_control.stop()
                case _:
                    data.update(commands_status.NOT_FOUND_COMMAND.response)
                    await websocket.send(message=dumps(data))

            if settings.commands_robot.is_command(command=command_name):
                data.update(commands_status.OK_COMMAND.response)
                await websocket.send(message=dumps(data))
    except exceptions.ConnectionClosed:
        pass
    except (exceptions.ConnectionClosedOK, exceptions.InvalidMessage, exceptions.InvalidState) as err:
        message_err = f'{err.__class__.__name__}: {err}'
        print(message_err)
        data.update(
            commands_status.SERVER_ERR.get_response_err(
                name_err=err.__class__.__name__, 
                message_err=err
            )
        )
        await websocket.send(message=dumps(data))
    finally:
        robot_control.close()

Разбор обновлённого кода:

  • robot_control = RobotControl() — создаёт объект для управления роботом через GPIO;

  • commands_status = FormResponse — подключает Enum с шаблонами стандартных ответов сервера;

  • command_name = data.get('command') — получает название команды из JSON, пришедшего от клиента;

  • match command_name: — выбирает действие робота в зависимости от команды;

  • robot_control.forward() и другие команды — выполняют движение робота: вперёд, назад, влево, вправо;

  • robot_control.stop() — останавливает движение робота;

  • case _: — обработка неизвестной команды:
    data.update(commands_status.NOT_FOUND_COMMAND.response) — добавляет в ответ статус 404 и сообщение об ошибке;
    await websocket.send(message=dumps(data)) — отправляет ответ клиенту через WebSocket.

  • if settings.commands_robot.is_command(command=command_name): — проверяет, является ли команда допустимой.
    data.update(commands_status.OK_COMMAND.response) — добавляет в ответ статус 200 и сообщение об успешном выполнении.

  • data.update в блоке с ошибками — добавляет информацию о серверной ошибке.
    commands_status.SERVER_ERR.get_response_err — формирует структуру ответа с названием и описанием ошибки.

  • robot_control.close() — завершает работу с GPIO, освобождая ресурсы.

Код для обработки команд стал более лаконичным и удобным. Теперь команды приходят в формате: {"command": "forward"} вместо прежнего вида:{"forward": true}. Остановка моторов выполняется через команду "stop". Логика программы избавлена от громоздких конструкций if...else, что делает код более чистым и понятным.

Ссылка на итоговый open-source проект robot-pi-service.


Код веб-приложения

Настало время обновить код веб-приложения, чтобы оно стало совместимо с новой логикой робота.

Обновлённый код для отправки команд из command.js:

// Другой код
const commandStop = {"command": "stop"};

// Назначение обработчиков событий для кнопки "Вперёд"
const forwardButton = document.getElementById("forward-button");
forwardButton.addEventListener("mousedown", () => startSendingCommand({"command": "forward"})); // Начало отправки команды
forwardButton.addEventListener("mouseup", () => {
    sendCommand(JSON.stringify(commandStop));
    stopSendingCommand();
}); // отправка команды forwardOff и остановка отправки при отпускании кнопки
forwardButton.addEventListener("mouseleave", stopSendingCommand); // Остановка отправки, если курсор уходит с кнопки
// Другой код

Разбор обновлённого кода:

  • const commandStop = {"command": "stop"}; — константа для команды stop;

  • startSendingCommand({"command": "forward"}) — фронтенд теперь отправляет команду в формате, который ожидает сервис робота (для остальных команд внесены аналогичные изменения);

  • sendCommand(JSON.stringify(commandStop)) — при отпускании кнопки отправляется команда stop (аналогично для других команд).

Теперь, когда структура команд обновлена, можно переходить к изменениям на бэкенде веб-приложения.

Добавляю недостающие переменные в .env.example:

# Урлы
STREAM_URL=адрес видеопотока
WEBSOCKET_URL=адрес вебсокета клиента
WEBSOCKET_URL_ROBOT=адрес вебсокета робота

# Команды для робота
FORWARD=вперёд
BACKWARD=назад
LEFT=влево
RIGHT=вправо
STOP=стоп

Теперь добавляю новую команду stop в класс CommandsRobot, который находится в файле settings.py.

Обновлённый класс CommandsRobot:

stop: str

Теперь все команды робота, включая остановку, имеют единый формат и могут использоваться фронтендом и бэкендом согласованно.

Завершающим шрихом изменяю код в эндпоинте для обработки команд websocket_endpoint() из файла views.py, чтобы он поддерживал новую структуру команд.

Обновлённый код эндпоинта websocket_endpoint():

@router.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket) -> None:
    """Эндпоинт для обработки команд фронтенда"""
    
    # Установка содединения по веб-сокету
    await websocket.accept()
    
    try:
        previous_command = None
        
        while True:
            # Получение команды от клиента (с веб-сокета)
            response = await websocket.receive_text()
            data = loads(response)
            command = data.get('command')

            if previous_command != command:
                previous_command = command
                valid_commands = settings.commands_robot.get_list_commands()
                
                if command in valid_commands:
                    # оптравка команды роботу
                    robot_answer = await command_to_robot(command=response)
                    
                    if robot_answer:
                        # отправка ответа робота на вебсокет фронтенда
                        await websocket.send_text(f'Получена команда: {command}, ответ робота: {robot_answer}')
    except WebSocketDisconnect:
        print('WebSocket отключен')
    except (WebSocketException, exceptions.InvalidMessage) as err:
        print(f'{err.__class__.__name__}: {err}')
    except JSONDecodeError:
        print('Ошибка при декодировании JSON')

Разбор обновлённого кода:

  • command = data.get('command') — получение команды теперь осуществляется по ключу command. Нет необходимости использовать старую конструкцию name_command = next(iter(data), None);

  • if command in valid_commands: — проверка команды выполняется через новую переменную command вместо старой name_command.

Теперь логика обработки команд стала короче, чище и полностью соответствует обновлённой структуре приложения.

Ссылка на итоговый open-source проект web-robot-control.


Управление моторами через веб-приложение

Перехожу к самой интересной части — проверке работы приложения вручную. Мне было важно оценить, насколько отзывчиво ведут себя моторы, а также как камера передаёт изображение при управлении. Главное — чтобы не было сильных рывков и зависаний видео при движении.

Запуск сервиса робота:

make run

Запуск веб-приложения:

poetry run start_app

Я записал короткое видео демонстрации работы управления двумя моторами через веб-приложение.

Как видно на видео, моторы реагируют быстро и точно на все команды, а камера передаёт изображение с достаточной плавностью для управления в реальном времени. Из-за небольшого расстояния приходится быстро отпускать кнопку, что иногда вызывает лёгкие рывки.


Заключение и планы на будущее

Основная часть функционала по управлению гусеничным шасси завершена. Удалось подключить к одноплатнику два мотора через драйвер L298N, а также управлять ими через веб-приложение в режиме реального времени.

В следующей статье планируется установить всю электронику на гусеничное шасси и провести тестирование на полосе препятствий, чтобы продемонстрировать работу платформы в действии.

Кроме того, на данный момент класс LedLineGpio остался неиспользованным. В предыдущей статье он применялся для управления LED-линией. В дальнейшем планируется добавить светодиод в качестве индикатора: он будет сигнализировать о готовности робота к работе и о факте соединения по WebSocket. При этом в веб-приложении появится возможность управлять индикатором.

Если у вас есть идеи по развитию проекта, поделитесь ими в комментариях — буду рад услышать ваши предложения!

Автор статьи @Arduinum


НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

Комментарии (0)


  1. proDream
    16.09.2025 11:19

    Интересная статья, но есть несколько замечаний:

    class CommandsRobot(ModelConfig):
        """Класс с командами для робота"""
    
        # Другой код
        stop: str
    
        def is_command(self, command: str) -> bool:
            """Есть ли команда в командах"""
    
            return command in self.model_dump().values()

    Тут у тебя при каждом обращении будет происходить сериализация текущего объекта класса, что не очень хорошо в плане производительности. Куда лучше завести отдельное поле которое один раз создаётся из дампа, а потом через property выдаёт из него значение. Либо уйти от Pydantic-Settings к Dynaconf, который один раз сериализует конфиг в объект настроек и можно будет получать доступ к словарям и вложенным элементам.

    В моём случае он хранит кортежи со статус-кодами и сообщениями, что делает ответы читаемыми и структурированными.

    class FormResponse(Enum):
        """Класс для формирования ответа"""
    
        NOT_FOUND_COMMAND = (404, 'Unknown command!')
        OK_COMMAND = (200, 'Command executed')
        SERVER_ERR = (500, 'Internal Server Error!')
    
        @property
        def response(self) -> dict[str, int | str]:
            """Словарь для формирования ответа серверу"""
    
            return {
                'status': self.value[0],
                'message': self.value[1]
            }

    Сугубо моё мнение, но использовать тапл как значение энума не лучшая идея и проблема такого использования прямо отражается в методах класса - неявное индексирование. Если тебе в другом месте понадобится использровать только текст команды или его код, будешь делать [0] или [1], что плохо с точки зрения явности. Я бы заменил весь класс на DictEnum и прописал конкретные ключи или, если хочешь остаться на кортежах, тогда NamedTuple в качестве значений. Это сильно повысит читаемость кода и использование значений вне методов класса.

    valid_commands = settings.commands_robot.get_list_commands()

    Это можно вынести наружу цикла while, иначе как я писал выше, каждый раз будет происходить сериализация класса с командами в JSON и выборкой из него. При том, что во время работы приложения класс не будет меняться.


    1. Arduinum
      16.09.2025 11:19

      Уходить с Pydantic-Settings более накладно поэтому ваш совет создавать из dump один раз выглядит более простым в осуществлении с минимумом изменений. Ресурсы важны поэтому совет хороший чтоб не гонять дамп каждый раз.

      Во втором совете логичнее NamedTuple чем DictEnum. Просто потому что там всего два значения всегда и одна структура DictEnum кажется избыточным в данном случае. Кстати насчёт NamedTuple есть вариант без него. Создать в init два поля status и message, а потом в property обращаться к ним через self.

      Насчёт valid_commands согласен недоглядел.

      Спасибо за советы.