В предыдущей статье я подключил два мотора к драйверу двигателей L298N. Сам драйвер управлялся с одноплатного компьютера Orange Pi Zero H+ через библиотеку gpiod, написанную на языке Python. Также я использовал avahi-daemon, чтобы задать для динамического IP одноплатника имя хоста, по которому к нему всегда можно обратиться, находясь в локальной сети.

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

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

Сборка робота

Основой для моего робота служит гусеничная платформа, которую я использовал в своей первой статье. Тогда ей управлял микроконтроллер Arduino в паре с российским драйвером двигателей ЛМ-130, основанным на базе L293D.

Для начала я установлю одноплатник и драйвер двигателей на кусок пластиковой пластины, к которой прикручу их винтами с гайками и пластиковыми шайбами:

Одноплатник и драйвер двигателей
Одноплатник и драйвер двигателей

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

Следующим шагом я прикручиваю пластину с платами к гусеничному шасси двумя винтами. Отверстия я просверлил заранее. Также я решил установить Wi-Fi-антенну вертикально. Правда, думаю, что стандартная антенна слишком маленькая, и есть подозрение, что сигнал будет ловиться не идеально. В будущем планирую заменить её на более мощную.

Установленные микросхемы на гусеничное шасси
Установленные микросхемы на гусеничное шасси

Старый пауэрбанк оказался, к сожалению, слишком слабым для моих целей — он выдавал всего 1 А на USB-выход. Этого недостаточно для полноценной работы устройства: моторы не развивают полную мощность, а при пиковых нагрузках одноплатник может просто выключиться. Поэтому я решил купить более мощный — выбор пал на модель от компании Energizer.

Пауэрбанк вид сверху
Пауэрбанк вид сверху

У данного пауэрбанк выходной ток составляет 2,1 А, что вдвое больше, чем у предыдущего. Также у него есть два USB-A-порта на выход и один USB-C, который работает и на вход, и на выход.

Пауэрбанк вид снизу
Пауэрбанк вид снизу

Сам пауэрбанк немного больше предыдущего, поэтому я решил установить его под углом. Для этого использовал пластиковый уголок для полок и резинку. Идея проста: уголок задаёт нужный наклон, а резинка прижимает пауэрбанк. Винты формируют трапециевидное натяжение резинки. К этой же конструкции будет прикреплена веб-камера на прищепке. Чтобы она не соскальзывала, я наклеил на пластину с обеих сторон двусторонний скотч, но не снимал наружный слой, чтобы не испачкать камеру клеем.

Передняя пластина
Передняя пластина

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

Установленная передняя пластина и веб-камера на гусеничное шасси
Установленная передняя пластина и веб-камера на гусеничное шасси

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

Также вы можете заметить установленный зелёный светодиод. О его сборке и настройке я расскажу в одном из следующих разделов статьи.

Установленный на робота пауэрбанк 
Установленный на робота пауэрбанк 

В завершение я добавил стильную наклейку FastAPI на передний «бронелист» робота (шасси снято с танка). Она подчёркивает связь сервиса робота с веб-приложением web-robot-control, написанное на FastAPI, которое запускается на ПК или vps-сервере.

Собранный робот
Собранный робот

Исправление багов и ошибок

gpio_control.py:

Начну с простой, но важной проблемы. Я перепутал направление вращения моторов при поворотах, поэтому просто меняю местами направления вращения в функциях left() и right().

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

def right(self) -> None:
    """Поворот робота направо"""
    
    self._right_motor.forward_motor()
    self._left_motor.backward_motor()

Теперь гусеничная платформа будет поворачивать правильно.

robot_pi_service.py:

Для более подробного понимания информации ниже рекомендую прочитать мою предыдущую статью, где подробно описаны классы MotorDCLineGpio() и RobotControl().

Поговорим об ошибках, которые могут внезапно появляться при, казалось бы, работоспособной программе. Я работал как обычно — и вдруг появились такие ошибки:

Скрытый текст
connection handler failed
Traceback (most recent call last):
  File "/home/name_user_orange_pi/code/robot-pi-service/robot_pi_service/robot_pi_service.py", line 18, in robot_control_gpio
    robot_control = RobotControl()
                    ^^^^^^^^^^^^^^
  File "/home/name_user_orange_pi/code/robot-pi-service/robot_pi_service/gpio_control.py", line 96, in __init__
    self._left_motor = MotorDCLineGpio(
                       ^^^^^^^^^^^^^^^^
  File "/home/name_user_orange_pi/code/robot-pi-service/robot_pi_service/gpio_control.py", line 44, in __init__
    self._request_in1 = request_lines(
                        ^^^^^^^^^^^^^^
  File "/home/name_user_orange_pi/code/venv_robot/lib/python3.12/site-packages/gpiod/__init__.py", line 119, in request_lines
    return chip.request_lines(
           ^^^^^^^^^^^^^^^^^^^
  File "/home/name_user_orange_pi/code/venv_robot/lib/python3.12/site-packages/gpiod/chip.py", line 331, in request_lines
    req_internal = cast(_ext.Chip, self._chip).request_lines(
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
OSError: [Errno 16] Device or resource busy

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/name_user_orange_pi/code/venv_robot/lib/python3.12/site-packages/websockets/asyncio/server.py", line 376, in conn_handler
    await self.handler(connection)
  File "/home/name_user_orange_pi/code/robot-pi-service/robot_pi_service/robot_pi_service.py", line 58, in robot_control_gpio
    robot_control.close()
    ^^^^^^^^^^^^^
UnboundLocalError: cannot access local variable 'robot_control' where it is not associated with a value

Разбор ошибок:

Первая ошибка: OSError: [Errno 16] Device or resource busy:

  • Происходит при вызове request_lines из библиотеки gpiod;

  • Линия GPIO уже занята другим процессом или объектом внутри программы;

  • Проявляется внезапно, даже если код работал раньше;

  • Возможные причины: предыдущий процесс не освободил GPIO, или какой-то объект типа LedLineGpio или MotorDCLineGpio остался активным;

  • Решение: проверять и корректно закрывать все объекты, работающие с GPIO, убедиться, что нет висящих процессов, использующих GPIO, и при необходимости добавить обработку исключения OSError.

Вторая ошибка: UnboundLocalError: cannot access local variable ‘robot_control’ where it is not associated with a value:

  • Происходит, когда переменная robot_control используется после падения конструктора RobotControl();

  • Python не может вызвать close() у переменной, которая не была создана;

  • Решение: инициализировать переменную перед try блоком и проверять её перед вызовом close(), например, присвоить None и вызывать close() только если объект существует.

Итог:

  • OSError указывает на проблему с занятыми линиями GPIO, требующую корректного освобождения ресурсов;

  • UnboundLocalError возникает как следствие падения конструктора и отсутствия проверки переменной перед использованием.

Прежде чем исправлять перечисленные ошибки, скажу заодно о ещё одной проблеме, которую хочу решить по ходу дела, так как код находится рядом. Это случай, когда явных ошибок нет, но есть ненужное расходование ресурсов. При каждом новом подключении по WebSocket пересоздаются объекты, которые могли бы жить всю сессию сервиса (например, RobotControl и FormResponse), — это повышает вероятность конфликтов с GPIO и просто тратит ресурсы на слабой одноплатной машине.

Старый фрагмент кода в robot_control_gpio():

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

    try:
        robot_control = RobotControl()
        commands_status = FormResponse
    # Другой код

Что здесь не так?
Экземпляры классов RobotControl и FormResponse создаются каждый раз, когда вызывается robot_control_gpio(). То есть при каждом переподключении клиента объекты пересоздаются, что приводит к лишней нагрузке. Для слабого одноплатника любая экономия ресурсов — это плюс, и с архитектурной точки зрения такой подход тоже лучше.

Поэтому логично перенести их в функцию start(), где объекты будут созданы один раз при запуске сервиса и больше не пересоздаваться. В robot_control_gpio() они будут передаваться как параметры.

from functools import partial
from gpiod import exception

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

from functools import partial:

  • Импорт функции partial, которая позволяет создавать новую функцию с заранее зафиксированными аргументами;

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

from gpiod import exception:

  • Импортирует модуль exception из библиотеки gpiod, где собраны классы ошибок, связанных с работой GPIO;

  • Позволяет обрабатывать специфические исключения, возникающие при взаимодействии с GPIO, например, при потере доступа к линии.

Далее добавляю в функцию start() новый функционал.

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

    print('Старт сервиса робота для приёма команд.')

    try:
        robot_control: RobotControl = RobotControl()
        commands_status: FormResponse = FormResponse
        
        async with serve(
            handler=partial(robot_control_gpio, robot_control=robot_control, commands_status=commands_status), 
            host=gethostbyname(settings.websocket_host), 
            port=settings.websocket_port
        ):
            # бесконечный цикл
            await asyncio.Future()
    except asyncio.CancelledError:
        if robot_control is not None:
            robot_control.blinking_off()
            robot_control.close()

        # пробрасываем CancelledError, чтобы asyncio.run() всё корректно закрыл
        raise

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

robot_control: RobotControl = RobotControl() и commands_status: FormResponse = FormResponse:

  • Теперь эти объекты создаются один раз внутри функции start() при запуске сервиса;

  • Это значит, что RobotControl больше не создаётся при каждом новом подключении по WebSocket — линии GPIO и другие ресурсы инициализируются только один раз, что снижает нагрузку и предотвращает ошибки вроде Device or resource busy;

  • Объекты сохраняются в памяти на всё время работы сервиса и передаются каждому новому соединению.

Работа partial:

  • Функция partial из модуля functools создаёт новую функцию с заранее переданными аргументами;

  • В данном случае она используется, чтобы передать robot_control и commands_status в хендлер robot_control_gpio, не изменяя сигнатуру функции serve();

  • Таким образом, при каждом новом подключении serve() вызывает robot_control_gpio, в которую уже автоматически подставлены нужные объекты.

Блок except asyncio.CancelledError:

  • Обрабатывает ситуацию, когда выполнение задачи (asyncio.run() или другой управляющий цикл) отменяется, например при завершении программы;

  • Проверка if robot_control is not None: гарантирует, что объект создан (помогает с ошибкой UnboundLocalError);

  • Вызовы blinking_off() и close() обеспечивают корректное завершение работы:
    1. blinking_off() останавливает мигание LED-индикатора;
    2. close() освобождает линии GPIO и другие аппаратные ресурсы.

  • После очистки ресурсов исключение CancelledError пробрасывается дальше, чтобы asyncio.run() мог завершить программу правильно.

Теперь остаётся обработать новые ошибки и передать в функцию robot_control_gpio() новые параметры:

async def robot_control_gpio(
    websocket: WebSocketServerProtocol,
    robot_control: RobotControl,
    commands_status: FormResponse
) -> None:

    """
    
    Асинхронная функция для управлением gpio робота (через websocket)
    
    """
    
    # Другой код
    except (exceptions.ConnectionClosed, exception.RequestReleasedError):
        pass
    except (exceptions.ConnectionClosedOK, exceptions.InvalidMessage, exceptions.InvalidState, OSError) as err:
        # Другой код

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

  • robot_control: RobotControl и commands_status: FormResponse — теперь передаются как аргументы функции;

  • OSError обработана — если линия GPIO занята, будет отправлено сообщение на веб-приложение;

  • exception.RequestReleasedError обработана — если линия уже закрыта, то программа просто продолжит работу, не вызывая ошибок.

Индикатор подключения из LED

Для индикации состояния подключения робота я задумал установить на него зелёный LED.

Идея простая:

  • Когда LED не горит — это значит, что сервис робота не включён и робот не готов к приёму команд;

  • Когда LED мигает — это означает, что сервис робота включён и готов к приёму команд;

  • Когда LED горит непрерывно — это значит, что робот принял команду и выполняет её.

Для работы с LED мне понадобится спаять небольшую микросхему, состоящую из маленькой макетной платы для пайки, светодиода, резистора на 150 Ом, пары проводов с дюпонтом «мама» и небольшого куска медного провода.

Компоненты
Компоненты

К плюсу LED (аноду) подпаиваю резистор, а к резистору — провод подключения.
К минусу (катоду) подпаиваю медный провод, к которому присоединяю второй провод подключения. Медный провод нужен просто для удлинения контакта. Также накручиваю на плату винты с гайками для установки.

Процесс пайки
Процесс пайки
Спаянная плата
Спаянная плата

Я просверлил пару отверстий и прикрутил винты к задней панели шасси.

Прикрученные винты к задней панели
Прикрученные винты к задней панели

Далее устанавливаю микросхему с LED и подключаю пины.

Установленная плата с LED
Установленная плата с LED

Теперь, когда плата подключена, я могу заняться написанием кода.
Я уже создал класс LedLineGpio для управления LED и рассказал об этом в данной статье. В ней же я подробно описал, как подключить LED к одноплатнику.

Однако стандартного включения и выключения LED мне недостаточно для моих задач, поэтому нужно расширить функционал класса LedLineGpio. Мне необходимо заставить LED мигать — и сделать это так, чтобы процесс мигания не блокировал основной поток. Иначе мигание LED заблокирует управление роботом. Кроме того, понадобится функционал для остановки процесса мигания.

gpio_control.py:

from gpiod import RequestReleasedError, # другие импорты
import asyncio

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

  • from gpiod import RequestReleasedError — импорт исключения, которое возникает, если линия GPIO была освобождена;

  • import asyncio — импорт стандартного модуля для работы с асинхронным кодом в Python (корутины, задачи, циклы событий).

class LedLineGpio:
    """Класс для управления линией GPIO для LED"""

    def __init__(self, line: int, gpio_path: str, consumer: str) -> None:
        # Другой код
        self._task: asyncio.Task | None = None

    async def _blinking(self) -> None:
        """Метод для мигания LED"""
        
        try:
            while True:
                self._request.set_value(self.line, Value.ACTIVE)
                await asyncio.sleep(0.2)
                self._request.set_value(self.line, Value.INACTIVE)
                await asyncio.sleep(0.2)
        except RequestReleasedError:
            pass
        except asyncio.CancelledError:
            # Корректно выключаем LED при остановке задачи
            self._request.set_value(self.line, Value.INACTIVE)
            raise

    def start_blinking(self) -> None:
        """Метод для добавления асинхронной задачи мигания LED"""
        
        if not self._task or self._task.done():
            self._task = asyncio.create_task(self._blinking())

    def stop_blinking(self) -> None:
        """Метод для остановки мигания LED"""
        
        if self._task and not self._task.done():
            self._task.cancel()
        self._request.set_value(self.line, Value.INACTIVE)
        
    # Другой код

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

self._task: asyncio.Task | None = None:

  • Объявление атрибута экземпляра класса, который будет хранить асинхронную задачу (task);

  • Типизация через | None указывает, что атрибут может быть либо объектом asyncio.Task, либо None (если задача ещё не создана);

  • Инициализация None показывает, что задача изначально не запущена.

async def _blinking(self) -> None:

  • Асинхронная функция для непрерывного мигания LED;

  • Цикл while True мигает светодиодом с интервалом 0.2 секунды;

  • Обрабатывает исключения:
    1. RequestReleasedError — если линия GPIO освобождена, просто пропускаем
    2. asyncio.CancelledError — при отмене задачи выключает LED и пробрасывает

  • исключение дальше, чтобы корректно завершить задачу.

def start_blinking(self) -> None:

  • Создает и запускает асинхронную задачу _blinking, если её ещё нет или предыдущая завершилась;

  • asyncio.create_task(self._blinking()) запускает корутину в фоне, позволяя остальному коду выполняться параллельно;

  • Гарантирует, что одновременно мигает только одна задача.

def stop_blinking(self) -> None:

  • Останавливает мигание, отменяя задачу через self._task.cancel();

  • После отмены сразу выключает LED (self._request.set_value(self.line, Value.INACTIVE));

  • Позволяет безопасно завершить работу с GPIO без зависших задач.

Таким образом создаётся асинхронная задача для мигания LED, которая не блокирует основной поток выполнения.

Далее нужно обновить класс RobotControl и встроить в него управление LED. Этот класс предназначен для удобного управления всеми функциями робота.

class RobotControl:
    """Класс для управления роботом"""
    
    def __init__(self) -> None:
        # Другой код
        self._led_indicator = LedLineGpio(
            line=settings.gpio_lines.led_line,
            gpio_path=settings.gpio_lines.gpio_path,
            consumer=settings.gpio_lines.led_consumer
        )
        self.ready_to_connect()
    
    def ready_to_connect(self) -> None:
        """Готовность робота к подключению (индикация)"""
        
        self._led_indicator.start_blinking()

    def connected(self) -> None:
        """Робот подключен (индикация)"""
        
        self._led_indicator.on()
    
    def blinking_off(self) -> None:
        """Выключение моргания LED"""
        
        self._led_indicator.stop_blinking()
    
    # Другой код
    
    def close(self) -> None:
        """Освобождение ресурса"""
        
        # Другой код
        self._led_indicator.close()

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

init(self) -> None:

  • Инициализация экземпляра робота;

  • Создает объект LedLineGpio для управления LED-индикатором;

  • Вызывает self.ready_to_connect(), чтобы сразу включить мигание LED, показывающее готовность к подключению.

ready_to_connect(self) -> None:

  • Включает мигание LED-индикатора через метод start_blinking();

  • Используется для визуального обозначения, что робот готов к подключению.

connected(self) -> None:

  • Переключает LED-индикатор в постоянное включение (on());

  • Показывает визуально, что робот подключён.

blinking_off(self) -> None:

  • Останавливает мигание LED через метод stop_blinking();

  • LED выключается, если нужно прекратить индикацию готовности.

close(self) -> None:

  • Освобождает ресурсы, связанные с LED-индикатором (вызывает close() у LedLineGpio);

  • Используется при завершении работы с роботом.

Теперь, когда код для управления LED полностью готов, его остается лишь встроить в сервис робота.

robot_pi_service.py:

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

    try:
        while True:
            try:
                command = await asyncio.wait_for(websocket.recv(), timeout=30.0)
                data = loads(command)
                command_name = data.get('command')

                if command_name != settings.commands_robot.stop:
                    robot_control.blinking_off()
                    robot_control.connected()
            except asyncio.TimeoutError:
                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()
                    robot_control.ready_to_connect()
                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, exception.RequestReleasedError):
        pass
    except (exceptions.ConnectionClosedOK, exceptions.InvalidMessage, exceptions.InvalidState, OSError) 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))

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

if command_name != settings.commands_robot.stop: — если команда не равна команде остановки:

  • robot_control.blinking_off() — останавливаем мигание LED;

  • robot_control.connected() — включаем LED на непрерывное горение.

case settings.commands_robot.stop: — если пришла команда остановки:

  • robot_control.ready_to_connect() — включаем режим мигание LED.


После этого, когда поступает любая команда, кроме stop, LED горит непрерывно, а при поступлении команды stop LED начинает мигать.

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

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

Прохождение полосы препятствий

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

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

make run

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

poetry run start_app

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

Робот успешно объехал все кегли, после чего развернулся задней частью к мячу и промахнулся по воротам. Далее я решил сбить все кегли, чтобы показать, что у меня не было цели идеально пройти полосу препятствий.

Что я могу сказать по поводу ощущений от управления роботом без веб-камеры с ПК?

  • Неудобно каждый раз поворачиваться, чтобы посмотреть на робота;

  • Управление с помощью мышки требует постоянного «тыканья», и быстрые переключения на другие клавиши делать трудно;

  • Восторг от того, что робот выполняет команды.

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

Что можно улучшить?

  • Добавить возможность управлять стрелками на клавиатуре для более быстрого переключения между клавишами управления;

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

  • Развернуть веб-приложение на VPS или создать мобильную версию. Смартфоном будет гораздо проще управлять роботом, особенно без веб-камеры.

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

Сегодня я собрал прототип робота и успешно управлял им без видеокамеры. Я рад, что моё детище потихоньку обретает работоспособность. Также хочу отметить, что использование LED в качестве индикации подключения робота сделало управление им более удобным.

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

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

Кроме того, я хочу внести ряд изменений в код, которые облегчат использование робота. Например, управление с клавиатуры на ПК, внедрение команды запуска видео стрима и возможность отключения веб-камеры. Дополнительно можно подключить логгер (logger) для более удобного выявления багов и сообщений робота.

У моего проекта всё ещё только начинается. Впереди ещё много улучшений и нововведений.

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

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


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

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