
В предыдущей статье я подключил веб-камеру, одноплатный компьютер Orange Pi Zero H+, драйвер двигателей L298N, светодиод (LED) и пауэрбанк к гусеничному шасси. Я написал код для LED, который используется в роли индикатора подключения. Также добавил обработку ошибок для линий GPIO. Я успешно протестировал управление роботом без веб-камеры, который прошёл небольшую полосу препятствий.
В этой статье я встрою команды для работы с веб-камерой в код сервиса робота. Wi-Fi антенна будет заменена на более крупную, что обеспечит более стабильный приём сигнала. Кроме того, я расширю управление, добавив поддержку клавиш клавиатуры — это позволит удобнее управлять роботом с ноутбука или ПК. После этих улучшений я поуправляю роботом от первого лица, наблюдая за происходящим через веб-камеру, и пройду более сложную полосу препятствий.
Статья будет полезна любителям DIY-проектов и веб-разработчикам, интересующимся фреймворком FastAPI.
Оглавление
Введение
Веб-камера — глаза робота. Пишу веб-приложение на FastAPI для управления DIY-проектом
Управление роботом с клавиатуры
Когда я тестировал управление роботом с помощью мыши на ПК, я понял, что это не очень удобно. Уходит время на то, чтобы отпустить мышь, навестись на другую кнопку и кликнуть — из-за этого появляется задержка, и управление роботом ощущается менее отзывчивым.
Чтобы повысить удобство управления на ПК и ноутбуке, я решил продублировать команды, добавив управление с клавиатуры. Для разнообразия я предусмотрел несколько групп клавиш: W, A, S, D, стрелки, а также 4, 8, 6, 2. Эти раскладки часто используются в компьютерных играх для управления движением персонажа или транспорта, поэтому они будут привычны и удобны пользователю.
Создавать отдельную обработку нажатий и отпусканий клавиш для каждой команды оказалось бы громоздко по количеству строк кода. Поэтому, чтобы сделать код более расширяемым и лаконичным, я написал универсальные функции для обработки нажатия и отпускания клавиш. Несколько клавиш, отвечающих за одну команду, я храню в списке, доступном по ключу в объекте.
Этот код я добавил в файл command.js, расположенный в папке static проекта web-robot-control. Тем, кто не знаком с этим файлом, рекомендую прочитать мою первую статью, где я подробно рассказал, как устроено управление роботом и каким образом фронтенд взаимодействует с бэкендом FastApi по websocket.
Код для управления с клавиатуры:
let command = null;
let currentCommand = null;
const keyMap = {
forwardKeys: ["w", "arrowup", "8"],
backwardKeys: ["s", "arrowdown", "2"],
leftKeys: ["a", "arrowleft", "4"],
rightKeys: ["d", "arrowright", "6"]
};
let activeKey = null; // конкретная активная клавиша
document.addEventListener("keydown", (event) => {
const key = event.key.toLowerCase();
// если уже нажата какая-то клавиша — игнорируем новую
if (activeKey) return;
if (keyMap.forwardKeys.includes(key)) {
command = "forward";
} else if (keyMap.backwardKeys.includes(key)) {
command = "backward";
} else if (keyMap.leftKeys.includes(key)) {
command = "left";
} else if (keyMap.rightKeys.includes(key)) {
command = "right";
} else {
return; // неуправляющая клавиша
}
if (command !== currentCommand) {
activeKey = key; // запоминаем, какая клавиша активна
currentCommand = command;
startSendingCommand({ "command": command });
}
});
document.addEventListener("keyup", (event) => {
const key = event.key.toLowerCase();
// сработает стоп только если отпущена именно активная клавиша
if (key === activeKey) {
sendCommand(JSON.stringify(commandStop));
stopSendingCommand();
activeKey = null;
command = null;
currentCommand = null;
}
});
Разбор кода
const keyMap = { ... }; — создаётся объект, содержащий массивы клавиш для управления движением:
forwardKeys — клавиши для движения вперёд ("w", "arrowup", "8");
backwardKeys — клавиши для движения назад ("s", "arrowdown", "2");
leftKeys — клавиши для движения влево ("a", "arrowleft", "4");
rightKeys — клавиши для движения вправо ("d", "arrowright", "6").
let activeKey = null; — переменная для хранения конкретной активной клавиши, которая сейчас нажата;
document.addEventListener("keydown", (event) => { ... }); — обработчик нажатия клавиши:
const key = event.key.toLowerCase();— приводит нажатую клавишу к нижнему регистру для унификации;if (activeKey) return;— если уже нажата другая клавиша управления, новая команда не обрабатывается (предотвращается одновременное движение в разные стороны);Определение команды:
- если клавиша есть в forwardKeys, то command = "forward";
- если в backwardKeys, то command = "backward";
- если в leftKeys, то command = "left";
- если в rightKeys, то command = "right";
- если клавиша не входит ни в один список — обработчик завершает выполнение (return).if (command !== currentCommand)— проверка, чтобы не перезапускать уже активную команду:
-activeKey = key;— сохраняется активная клавиша;
-currentCommand = command;— фиксируется текущая команда;
-startSendingCommand({ "command": command });— запускается отправка команды на выполнение движения робота.
document.addEventListener("keyup", (event) => { ... }); — обработчик отпускания клавиши:
const key = event.key.toLowerCase();— определяет, какая клавиша была отпущена;if (key === activeKey)— проверяет, совпадает ли отпущенная клавиша с активной:
-sendCommand(JSON.stringify(commandStop));— отправляет команду остановки робота;
-stopSendingCommand();— прекращает отправку управляющих сигналов;
-activeKey = null; command = null; currentCommand = null;— сбрасывает состояние, подготавливая систему к следующему вводу.
Улучшение кода для управления мышкой
После того как я написал универсальную обработку управления с клавиатуры и взглянул на старый код из файла 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 leftButton = document.getElementById("left-button");
leftButton.addEventListener("mousedown", () => startSendingCommand({ "command": "left" }));
leftButton.addEventListener("mouseup", () => {
sendCommand(JSON.stringify(commandStop));
stopSendingCommand();
});
leftButton.addEventListener("mouseleave", stopSendingCommand);
// Назначение обработчиков событий для кнопки "Вправо"
const rightButton = document.getElementById("right-button");
rightButton.addEventListener("mousedown", () => startSendingCommand({ "command": "right" }));
rightButton.addEventListener("mouseup", () => {
sendCommand(JSON.stringify(commandStop));
stopSendingCommand();
});
rightButton.addEventListener("mouseleave", stopSendingCommand);
// Назначение обработчиков событий для кнопки "Назад"
const backwardButton = document.getElementById("backward-button");
backwardButton.addEventListener("mousedown", () => startSendingCommand({ "command": "backward" }));
backwardButton.addEventListener("mouseup", () => {
sendCommand(JSON.stringify(commandStop));
stopSendingCommand();
});
backwardButton.addEventListener("mouseleave", stopSendingCommand);Теперь я решил применить немного иную стратегию написания кода. Обработчик команд будет проще, чем для управления с клавиатуры, поскольку здесь нужны только id кнопки и сама команда. Для каждой кнопки обработчик инициализируется отдельно при вызове функции, в которую передаются id и команда.
Обновлённый код для команд:
// Другой код
// Команда остановки
const commandStop = { "command": "stop" };
let command = null;
let currentCommand = null;
// Управление кнопками мыши
function robotControlButton(buttonId, commandName) {
const button = document.getElementById(buttonId);
if (!button) return;
button.addEventListener("mousedown", () => {
if (currentCommand !== commandName) {
currentCommand = commandName;
startSendingCommand({ command: commandName });
}
});
const stopHandler = () => {
if (currentCommand === commandName) {
sendCommand(JSON.stringify(commandStop));
stopSendingCommand();
currentCommand = null;
}
};
button.addEventListener("mouseup", stopHandler);
button.addEventListener("mouseleave", stopHandler);
}
// Инициализация кнопок
robotControlButton("forward-button", "forward");
robotControlButton("backward-button", "backward");
robotControlButton("left-button", "left");
robotControlButton("right-button", "right");
Разбор кода
const commandStop = { "command": "stop" }; — создаётся объект с единственным свойством "command": "stop", который служит сигналом для остановки робота;
let command = null; let currentCommand = null; — переменные для хранения активной команды:
command— может использоваться для текущего запроса;currentCommand— показывает, какая команда сейчас выполняется (например, "forward");
function robotControlButton(buttonId, commandName) — функция, отвечающая за поведение кнопки управления:
const button = document.getElementById(buttonId);— получает элемент кнопки по её идентификатору;if (!button) return;— если кнопка не найдена, функция завершается;button.addEventListener("mousedown", ... )— добавляет обработчик нажатия кнопки мыши:
— если текущая команда(currentCommand)не совпадает с переданной(commandName):
- присваивается новая команда вcurrentCommand;
- вызывается функцияstartSendingCommand({ command: commandName });, которая начинает отправку команды роботу.const stopHandler = () => { ... }— функция, выполняющая остановку робота:
- проверяет, что текущая активная команда совпадает с переданной;
- отправляет команду остановкиsendCommand(JSON.stringify(commandStop));;
- вызываетstopSendingCommand();, чтобы прекратить отправку сигналов;
- сбрасывает значениеcurrentCommandвnull.button.addEventListener("mouseup", stopHandler);— при отпускании кнопки мыши выполняется остановка;button.addEventListener("mouseleave", stopHandler);— если курсор уходит за пределы кнопки, также выполняется остановка;
robotControlButton("forward-button", "forward"); — инициализация кнопки движения вперёд;
robotControlButton("backward-button", "backward"); — инициализация кнопки движения назад;
robotControlButton("left-button", "left"); — инициализация кнопки поворота влево;
robotControlButton("right-button", "right"); — инициализация кнопки поворота вправо.
Ссылка на итоговый open-source проект web-robot-control.
Запуск видеострима из кода сервиса
До этого момента команда для видеострима запускалась мной вручную в отдельном ssh окне терминала. Такой запуск не очень удобен для обычного пользователя, особенно того, кто не знаком с консолью Linux в принципе. Поэтому я решил встроить эту команду в код сервиса робота.
Для удобного управления я придумал три env переменные:
Первая содержит команду видеострима. Мой код не привязан к конкретной программе для видеострима, поэтому пользователь может использовать любую на своё усмотрение;
Вторая будет переменной-флагом, которая отвечает за включение и выключение видеострима. У пользователя может не быть веб-камеры или он может просто захотеть управлять роботом без видео стрима;
Третья будет отвечать за флаг, который включает и выключает подробное отображение запуска видеострима в консоли. Это полезно для отладки.
.env.example:
# Команды Linux
VIDEOSTREAM=команда видеострима
ON_VIDEOSTREAM=включить видеострим
ON_STREAM_DEBUG=включить дебаг видеострима
.env:
VIDEOSTREAM=mjpg_streamer -i "input_uvc.so -d /dev/video0 -r 320x240 -f 15 -q 80" -o "output_http.so -p 8096 -w /usr/local/share/mjpg-streamer/www" &
ON_VIDEOSTREAM=True
ON_STREAM_DEBUG=True
Подробнее о команде в переменной VIDEOSTREAM можно узнать в данной статье, где я рассказывал о видеостриме.
Далее я написал код для чтения новых env переменных в Python-коде сервиса робота.
settings.py:
class CommandsLinux(ModelConfig):
"""Класс с командами для линукса"""
videostream: str
on_videostream: bool
on_stream_debug: bool
Разбор кода
class CommandsLinux(ModelConfig): — создаётся класс конфигурации, содержащий команды для Linux:
videostream: str— строковое поле, хранящее команду для запуска видеопотока;on_videostream: bool— логическое значение, указывающее, активен ли видеопоток;sream_debug: bool— логическое значение, определяющее, нужно ли включать режим отладки видеопотока.
class Settings(ModelConfig):
"""Класс для данных конфига"""
# Другой код
commands_linux: CommandsLinux = CommandsLinux()
Разбор нового кода
class Settings(ModelConfig): — класс конфигурации приложения;commands_linux: CommandsLinux = CommandsLinux()— создаётся вложенный объект настроек CommandsLinux, который хранит команды и флаги, относящиеся к Linux-системе.
Последний штрих — я встроил запуск видеострима в Python-код, который отвечает за запуск сервиса. По моей задумке, код запускает видеострим в отдельном процессе, так как хорошо распределяет ресурсы одноплатника. Запуск в одном процессе на слабых одноплатниках череват зависанием всего приложения.
robot_pi_service.py:
# Другой код
from subprocess import Popen, DEVNULL
from os import setpgrp, killpg, getpgid
from time import sleep
Разбор импортов
from subprocess import Popen, DEVNULL — импортируются функции и константы из модуля subprocess:
Popen— используется для запуска внешних процессов (например, выполнения системных команд);DEVNULL— специальный объект, позволяющий перенаправлять вывод процесса «в никуда» (чтобы скрыть вывод).
from os import setpgrp, killpg, getpgid — импортируются функции управления процессами из модуля os:
setpgrp()— создаёт новую группу процессов для запущенного процесса;killpg()— отправляет сигнал (например, остановку) всей группе процессов;getpgid()— возвращает идентификатор группы процесса по PID.
from time import sleep — импортируется функция sleep(), позволяющая приостанавливать выполнение программы на заданное количество секунд (часто используется для ожидания между командами или проверками).
def run_app() -> None:
"""Функция старта приложения"""
command_stream = None
try:
if settings.commands_linux.on_videostream:
command_stream = Popen(
settings.commands_linux.videostream,
shell=True,
preexec_fn=setpgrp,
stderr=None if settings.commands_linux.on_stream_debug else DEVNULL
)
sleep(0.1)
print('Видеострим запущен.')
asyncio.run(start())
except KeyboardInterrupt:
try:
if settings.commands_linux.on_videostream and command_stream:
killpg(getpgid(command_stream.pid), SIGTERM)
except ProcessLookupError:
# Если процесс был уже завершён
pass
finally:
print('Выключение сервиса робота для приёма команд.')
Разбор обновлённого кода
command_stream = None — создаётся переменная для хранения объекта процесса, отвечающего за видеопоток. Изначально значение None показывает, что процесс ещё не запущен;
Popen объект — используется для запуска внешнего процесса (в данном случае — видеострима):
settings.commands_linux.videostream— строка с командой, которую нужно выполнить (например, запуск видеопотока черезffmpegилиv4l2);shell=True— позволяет выполнять команду через оболочку(bash/sh), что удобно при сложных строках команд, но требует осторожности из-за возможных уязвимостей;preexec_fn=setpgrp— создаёт отдельную группу процессов для видеострима. Это позволяет потом корректно завершить основной процесс вместе со всеми его дочерними;stderr=None if settings.commands_linux.on_stream_debug else DEVNULL— определяет, куда направлять поток ошибок:
- если включён режим отладки(on_stream_debug=True), ошибки выводятся в стандартный поток;
- иначе они перенаправляются вDEVNULL, чтобы скрыть сообщения.
sleep(0.1)— делает небольшую паузу (0.1 секунды) после запуска процесса, чтобы дать видеопотоку время на инициализацию перед выполнением следующих операций;print('Видеострим запущен.')— выводит уведомление в консоль, подтверждающее успешный запуск видеопотока;try блок:
-if settings.commands_linux.on_videostream and command_stream:— проверяется, был ли включён видеопоток и существует ли запущенный процесс;
-killpg(getpgid(command_stream.pid), SIGTERM)— посылает сигнал завершения (SIGTERM) всей группе процессов видеострима по её идентификатору.
except ProcessLookupError: — обрабатывает исключение, если попытка завершить процесс не удалась (например, видеопоток уже был завершён ранее):
-pass— игнорирует ошибку и продолжает выполнение программы.
finally блок:
- выполняется в любом случае (даже при ошибках или прерывании работы);
-print('Выключение сервиса робота для приёма команд.')— выводит сообщение о завершении работы сервиса, отвечающего за приём команд робота.
Логика для запуска видеострима из Python кода готова.
Улучшение кода в robot_pi_service.py
Я заметил ещё несколько мест для оптимизации в коде сервиса робота и решил улучшить их. Первым делом я заменил pass на return в блоке обработки ошибок.
Фрагмент кода из robot_control_gpio():
except (exceptions.ConnectionClosed, exception.RequestReleasedError):
pass
Полный код данной функции из пятой статьи можно найти по ссылке на ветку в репозитории проекта.
Почему использовать pass не совсем верно?
passничего не делает при одной из двух ошибок;Так как внутри
robot_control_gpio()используется бесконечный циклwhile True, выполнение цикла продолжится, несмотря на то, чтоWebSocketсоединение уже закрыто;Попытка дальше читать данные через
await websocket.recv()приведёт к новым исключениям (ConnectionClosed,InvalidState,OSError) и может вызвать зацикливание функции;Ресурсы, связанные с задачей и соединением, не освобождаются корректно, что увеличивает нагрузку на приложение и может привести к утечкам памяти.
except (exceptions.ConnectionClosed, exception.RequestReleasedError):
return
Для чего использовать return?
Завершает выполнение функции сразу, выходя из бесконечного цикла и освобождая ресурсы;
Предотвращает бесконечные попытки чтения из разорванного
WebSocket;
Ещё один более критичный момент я обнаружил в функции start(). Проблема заключалась в том, что ресурсы robot_control, содержащие код управления роботом и линиями GPIO, освобождались только при обработке ошибки asyncio.CancelledError. На практике освобождать эти ресурсы нужно всегда при завершении работы приложения, если объект существует, независимо от того, произошла ли ошибка или нет.
Часть старого кода функии start():
except asyncio.CancelledError:
if robot_control is not None:
robot_control.blinking_off()
robot_control.close()
# пробрасываем CancelledError, чтобы asyncio.run() всё корректно закрыл
raise
Обновлённый код:
except (asyncio.CancelledError, OSError, exception.RequestReleasedError) as err:
# пробрасываем ошибку, чтобы asyncio.run() всё корректно закрыл
message_err = f'{err.__class__.__name__}: {err}'
print(message_err)
raise
finally:
if robot_control is not None:
robot_control.blinking_off()
robot_control.close()
Какие преимущества у обновлённого кода?
Явное разделение логики ошибок и отмены — OSError и RequestReleasedError можно логировать, но их обработка не мешает корректной отмене задач.
Уверенность в освобождении ресурсов —
robot_control.blinking_off()и robot_control.close() вызываютс до проброса CancelledError, предотвращая утечки:
-robot_control.blinking_off()— выключает мигание LED;
-robot_control.close()— закрывает ресурсы робота.
Ссылка на итоговый open-source проект robot-pi-service.
Замена Wi-Fi антенны
Во время тестирования управления роботом я заметил частые зависания видео. Особенно это проявлялось, когда робот отъезжал далеко от роутера или поворачивался передней частью к источнику сигнала. Как правило, в таких ситуациях виновником становится либо слабая Wi-Fi антенна, либо низкая скорость передачи данных, которой не хватает для стабильного приёма видеопотока в реальном времени.
С пропускной способностью Wi-Fi 802.11n на моей модели одноплатного компьютера Orange Pi Zero H2+ всё более-менее понятно. В домашних условиях его реальная скорость — около 10–20 Мбит/с. Для видео с разрешением 320×240 этого более чем достаточно — хватит даже 6 Мбит/с, а при сильном сжатии видео может работать и на 1 Мбит/с (иногда даже меньше).
Ещё может влиять радио-шум в диапазоне 2.4 ГГц:
Bluetooth-устройства (наушники, геймпады, мыши) — делят тот же диапазон, создавая кратковременные коллизии;
Микроволновки — излучают мощный шум на ~2.45 ГГц, особенно заметный при работе;
Беспроводные клавиатуры, “умные” лампы, камеры — заполняют эфир фоновыми пакетами;
Соседские Wi-Fi-роутеры — перекрывают каналы, особенно если работают на 1, 6 или 11-м канале;
Старые радиотелефоны, видеоняни, игрушки — создают случайные всплески шума.
Физические преграды и материалы — ослабляют или отражают сигнал, увеличивая потери и задержки:
Дерево, пластик, гипс — незначительное затухание (-1…-3 dB);
Кирпич, бетон — среднее ослабление (-6…-15 dB);
Железобетон, металл — сильное экранирование (-20…-30 dB);
Вода и тело человека — поглощают сигнал (-10…-20 dB).
Мне сразу пришла идея установить большую антенну, чтобы улучшить качество связи. В наборе от платы ESP32 у меня была Wi-Fi антенна с разъёмом IPEX. Такую антенну можно приобрести и отдельно.

Преимущество большой Wi-Fi антенны
1. Усиление сигнала (Gain):
Большая антенна имеет большее усиление (обычно 5–9 dBi против 2 dBi у маленьких);
Это значит, что она принимает и передаёт сигнал дальше, особенно в одном направлении (у направленных моделей).
2. Улучшение стабильности связи:
За счёт лучшего приёма слабых сигналов, соединение с роутером становится менее подвержено потерям пакетов, особенно при преградах или на расстоянии;
Это повышает скорость передачи данных и уменьшает пинг.
3. Влияние на качество видео при его передаче по Wi-Fi:
Большая антенна даёт более стабильный видеопоток без прерываний;
Меньше артефактов и “замираний” видео, особенно при высоком битрейте;
Меньше задержка (lag) при работе в реальном времени.
Для крепления антенны я сделал держатель из пластика старой скидочной карты. Он представляет собой пластину с двумя отверстиями: в меньшее вставляется винт для крепления держателя к корпусу робота, а в большее — крепление самой антенны.

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

Затем прикрутил антенну к латунному держателю и установил сам держатель на корпус робота. Для этого пришлось просверлить отверстие в корпусе.

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

Управление роботом через веб-камеру
Теперь перехожу к самой волнительной и интересной стадии — испытаниям управления роботом. Мне было особенно интересно, насколько хорошо будет работать управление с клавиатуры, появятся ли задержки при передаче команд и будут ли ложные срабатывания. Главный интерес — это качество видеопотока и ощущения от управления роботом «от первого лица» через веб-камеру.
Запуск сервиса робота:
(venv_robot) $ make run
Если ON_STREAM_DEBUG=False будет следующий вывод:
Видеострим запущен.
Старт сервиса робота для приёма команд.
Если ON_STREAM_DEBUG=True будет виден подробный вывод команды для видеострима:
Посмотреть вывод
MJPG Streamer Version: git rev: 310b29f4a94c46652b20c4b7b6e5cf24e532af39
i: Using V4L2 device.: /dev/video0
i: Desired Resolution: 320 x 240
i: Frames Per Second.: 15
i: Format............: JPEG
i: TV-Norm...........: DEFAULT
i: FPS coerced ......: from 15 to 30
UVCIOC_CTRL_ADD - Error at Pan (relative): Inappropriate ioctl for device (25)
UVCIOC_CTRL_ADD - Error at Tilt (relative): Inappropriate ioctl for device (25)
UVCIOC_CTRL_ADD - Error at Pan Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_ADD - Error at Tilt Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_ADD - Error at Pan/tilt Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_ADD - Error at Focus (absolute): Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Pan (relative): Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Tilt (relative): Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Pan Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Tilt Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Pan/tilt Reset: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Focus (absolute): Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at LED1 Mode: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at LED1 Frequency: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Disable video processing: Inappropriate ioctl for device (25)
UVCIOC_CTRL_MAP - Error at Raw bits per pixel: Inappropriate ioctl for device (25)
o: www-folder-path......: /usr/local/share/mjpg-streamer/www/
o: HTTP TCP port........: 8070
o: HTTP Listen Address..: (null)
o: username:password....: disabled
o: commands.............: enabled
Подробнее о настройках видеострима и расшифровке его вывода можно узнать в этой статье.
Запуск веб-приложения:
poetry run start_app
Вывод в terminal:
INFO: Will watch for changes in these directories: ['/media/user_name/Files2/Programming/Python/Projects/web-robot-control']
INFO: Uvicorn running on http://127.0.0.1:8020 (Press CTRL+C to quit)
INFO: Started reloader process [57719] using WatchFiles
INFO: Started server process [57795]
INFO: Waiting for application startup.
INFO: Application startup complete.
Первым делом я решил протестировать, как робот управляется в простых домашних условиях.
На коротком видео видно, как робот заезжает в ванную через порог, который создаёт довольно сильный уклон. Благодаря гусеничному приводу он спокойно его преодолевает и возвращается обратно в коридор. Роутер стоит в коридоре, поэтому это тест в самых благоприятных условиях приёма сигнала.
В таких условиях я не заметил никаких задержек в видео и подвисаний команд. Управление с клавиатуры оказалось гораздо удобнее, чем с помощью мыши: клавиши реагируют быстрее, и ощущение контроля над роботом стало значительно лучше.
С первым тестом робот справился отлично, но теперь я решил усложнить задачу. Для этого сделал полосу препятствий, чтобы протестировать управление в более экстремальных условиях. Это поможет выявить сильные и слабые стороны системы и просто весело провести время, управляя роботом.

На полосе препятствий можно заметить: флажки линии старта, две кегли, наклон с поворотом, разбросанные детали LEGO и мяч с воротами в виде коробки. Когда смотришь со стороны, кажется, что пройти такую полосу легко. Но всё меняется, когда управляешь роботом, глядя только в изображение с веб-камеры с небольшим углом обзора. К тому же пилот не видит, что происходит сбоку или сзади робота — это добавляет ещё больше сложности и делает процесс действительно увлекательным.
В начале видео робот стартовал и сразу заехал на покрышку флага, далее слегка задел кеглю, но не уронил её. Как я писал выше, тому, кто управляет роботом, не видны препятствия сбоку и сзади. Это добавляет некоторой сложности управления, в которой есть спортивный интерес — пройти полосу без ошибок.
Когда робот поднялся на уклон и смотрел вверх, я слегка потерял ориентацию в пространстве и сильно завернул вправо. Когда верх задран, трудно понять, что находится на дороге.
Каким-то чудом я не опрокинулся на бок, когда съезжал вниз по наклону. У меня был неудачный дубль, где я перевернулся на бок в том месте. При сильном уклоне вниз тоже плохо видно дорогу.
Под столом робот наехал днищем на деталь LEGO, поэтому пришлось немного покрутиться в стороны, чтобы освободиться от буксования на месте. Также было небольшое ухудшение сигнала под столом. В конце я постарался забить мяч в ворота.
Ощущения от управления интересные и захватывающие. Было бы неплохо повесить пару датчиков препятствий с двух сторон и один сзади робота, чтобы понимать, есть ли объекты в слепых зонах. Задержка видео в идеальных условиях незаметна, а на кухне чувствуется небольшая из-за большего количества преград — столов и стульев, которые экранируют сигнал. В любом случае это довольно интересный опыт — увидеть квартиру и вещи в ней с нового для себя ракурса.
Заключение и планы на будущее
Сегодня я установил большую Wi-Fi антенну, что улучшило качество сигнала. Написал универсальный обработчик команд для управления мышью и клавиатурой, встроил команду видеострима в код сервиса робота, улучшил обработку ошибок и прошёл полосу препятствий от первого лица, глядя в веб-камеру.
Сейчас проект находится примерно на середине стадии MVP 1. Несмотря на то, что роботом уже можно управлять, это приложение ещё нельзя давать обычному пользователю. Пока оно скорее выглядит как dev-вариант с минимально рабочим функционалом, который больше понятен программисту.
В следующей статье я начну делать приложение удобным для обычного пользователя. Сначала я упрощу запуск веб-приложения, создав для него Docker-контейнер. Сейчас приложение запускается только в режиме разработки с использованием виртуального окружения.
Кроме того, я настрою автоматическую сборку Docker-контейнера через GitHub Actions, чтобы реализовать полноценный CI/CD: при каждом коммите в репозиторий контейнер будет автоматически собираться, проходить тесты (пока отсутствуют) и отправляться на Docker Hub. Пользователю останется лишь заполнить .env файл и развернуть контейнер с веб-приложением по инструкции на своём локальном компьютере или VPS-сервере.
Если у вас есть идеи по развитию проекта, поделитесь ими в комментариях — буду рад услышать ваши предложения!
Автор статьи @Arduinum
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
Комментарии (4)

NosferatuDima2
02.12.2025 10:32Можно отправлять на Марс

Arduinum
02.12.2025 10:32Было бы очень круто построить хотя бы модель марсохода действующую). Наверное задержка дикая была-бы управлять этим с земли.
proDream
Не критика, но замечание. Сдаётся мне, что вот это:
Можно солидно так оптимизировать, вынеся определение команды в небольшую функцию.
Arduinum
Больше оптимизации богу оптимизации)