
В предыдущей статье я показал, как настроить 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:
Пины 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
.
После написания этого класса его уже можно использовать в сервисе робота. Но есть несколько неудобств:
Нужно создавать два экземпляра класса для каждого мотора и передавать в них параметры линий;
Для управления одним мотором приходится вызывать сразу два метода;
Такой способ не масштабируется — он неудобен для платформ с четырьмя и более моторами.
Чтобы решить эти проблемы, я написал класс-«интерфейс» для управления роботом. Он берёт на себя создание экземпляров классов для моторов, настройку 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.
proDream
Интересная статья, но есть несколько замечаний:
Тут у тебя при каждом обращении будет происходить сериализация текущего объекта класса, что не очень хорошо в плане производительности. Куда лучше завести отдельное поле которое один раз создаётся из дампа, а потом через property выдаёт из него значение. Либо уйти от Pydantic-Settings к Dynaconf, который один раз сериализует конфиг в объект настроек и можно будет получать доступ к словарям и вложенным элементам.
Сугубо моё мнение, но использовать тапл как значение энума не лучшая идея и проблема такого использования прямо отражается в методах класса - неявное индексирование. Если тебе в другом месте понадобится использровать только текст команды или его код, будешь делать [0] или [1], что плохо с точки зрения явности. Я бы заменил весь класс на
DictEnum
и прописал конкретные ключи или, если хочешь остаться на кортежах, тогда NamedTuple в качестве значений. Это сильно повысит читаемость кода и использование значений вне методов класса.Это можно вынести наружу цикла while, иначе как я писал выше, каждый раз будет происходить сериализация класса с командами в JSON и выборкой из него. При том, что во время работы приложения класс не будет меняться.
Arduinum
Уходить с Pydantic-Settings более накладно поэтому ваш совет создавать из dump один раз выглядит более простым в осуществлении с минимумом изменений. Ресурсы важны поэтому совет хороший чтоб не гонять дамп каждый раз.
Во втором совете логичнее NamedTuple чем DictEnum. Просто потому что там всего два значения всегда и одна структура DictEnum кажется избыточным в данном случае. Кстати насчёт NamedTuple есть вариант без него. Создать в init два поля status и message, а потом в property обращаться к ним через self.
Насчёт valid_commands согласен недоглядел.
Спасибо за советы.