В предыдущей статье я показал, как настроить одноплатный компьютер Orange Pi Zero для работы. Написал обратный прокси на Nginx, который перенаправляет видеострим. Также реализовал сервис robot_pi_service для приёма команд от веб-приложения и отправки ответов. В веб-приложение добавил код для отправки команд роботу и получения ответов.

В третьей части статьи я расскажу, как управлять GPIO-пинами одноплатника на примере Orange Pi Zero с помощью Python. Я покажу, как подключить светодиод (LED) и управлять им через веб-приложение. Также проведу отладку задержек.

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

Настройка GPIO на одноплатнике

GPIO (General Purpose Input/Output) — это универсальные пины, которые имеются как на микроконтроллерах, так и на одноплатных компьютерах, и могут настраиваться программно для приёма или передачи цифровых сигналов. С их помощью устройство может взаимодействовать с внешним миром — например, считывать данные с датчиков или управлять светодиодами, реле и другими компонентами.

Знакомство с чем-то новым часто начинают с простого. Изучение языка программирования — с вывода строки Hello, World!, а первая программа для работы с GPIO — с управления светодиодом (LED).

Для управления LED я подключу его к GPIO одноплатника. На каждом одноплатнике схема портов GPIO отличается.

Распиновка GPIO для Orange Pi Zero H+
Распиновка GPIO для Orange Pi Zero H+

Для подключения светодиода мне потребуются два пина:

  • PWM1 (PA6) — физический пин 20;

  • GND — физический пин 7 (земля).

Схема подключения LED:

  • PWM1 (PA6) → резистор 150 Ом → анод LED (длинная ножка);

  • GND → катод LED (короткая ножка).

Резистор обязателен — он снижает силу тока, чтобы не спалить светодиод.

Фото подключенного LED к одноплатнику
Фото подключенного LED к одноплатнику

Для удобной работы с GPIO я устанавливаю библиотеку gpiod в Armbian Linux на одноплатнике:

sudo apt install gpiod

Установка библиотеки gpiod в виртуальное окружение Python:

pip install gpiod

Теперь можно работать с GPIO как из Linux, так и из Python. После установки библиотеки проверяю, какие GPIO-контроллеры доступны физически.

gpiodetect

Результат команды:

gpiochip0 [1c20800.pinctrl] (224 lines)  
gpiochip1 [1f02c00.pinctrl] (32 lines)

Разбор результата:

  • gpiochip0 и gpiochip1 — это устройства GPIO-чипов, доступные в системе через /dev/gpiochip*;

  • 1c20800.pinctrl и 1f02c00.pinctrl — названия драйверов или контроллеров, с которыми связаны эти чипы. Они определяются ядром Linux (обычно из Device Tree);

  • (224 lines) и (32 lines) — количество GPIO-линий, доступных в каждом чипе. Это говорит, сколько отдельных входов/выходов можно использовать.

Мне нужно устройство gpiochip0, которое охватывает большинство основных GPIO-пинов. На этом устройстве я найду нужную мне линию, которая нужна мне для управления LED.

Проверка линий:

gpioinfo gpiochip0

Результат команды:

gpiochip0 - 224 lines:
       line   0:      unnamed       unused   input  active-high    
       line   1:      unnamed       unused   input  active-high    
       line   2:      unnamed       unused   input  active-high    
       line   3:      unnamed       unused   input  active-high    
       line   4:      unnamed       unused   input  active-high    
       line   5:      unnamed       unused   input  active-high    
       line   6:      unnamed       unused  output  active-high
       ...

Разбор результата:

  • gpiochip0 - 224 lines: — устройство содержит 224 линии, которые являются внутренними логическими каналами;

  • line — индекс линии внутри чипа;

  • unnamed — линия не имеет имени;

  • unused — доступна для использования;

  • output — линия настроена на выход (подходит для управления LED, зуммерами и т. д.);

  • input — линия настроена на вход (используется для кнопок, датчиков);

  • active-high — активный сигнал соответствует логической единице (HIGH).

Мне нужна line 6, которая соответствует PA6 на GPIO Orange Pi. Её я и буду использовать для управления LED.

Подаю логическую единицу, чтобы включить LED и проверить его работу:

sudo gpioset gpiochip0 6=1
Фото горящего LED
Фото горящего LED

Красный светодиод успешно загорелся. Первый раз, когда я его включал, перепутал провода и проверял, включён ли LED программно.

Проверка состояния линии:

sudo gpioget gpiochip0 6

Результат команды:

1

Результат 1 означает, что line 6 включена (HIGH).

Для выключения подаю 0:

sudo gpioset gpiochip0 6=0

Чтобы работать с GPIO без sudo, нужны права суперпользователя. Я напишу скрипт sh, который создаст группу gpio и даст текущему пользователю доступ к GPIO.

Файл gpio_setup.sh:

#!/bin/bash
chmod -R +x robot_pi_service/
sudo groupadd gpio
sudo usermod -aG gpio $USER
echo 'SUBSYSTEM=="gpio", KERNEL=="gpiochip[0-9]*", GROUP="gpio", MODE="0660"' | sudo tee /etc/udev/rules.d/99-gpio.rules
sudo udevadm control --reload-rules
sudo udevadm trigger

Разбор скрипта:

  • sudo groupadd gpio — создаёт группу gpio;

  • sudo usermod -aG gpio $USER — добавляет текущего пользователя в группу gpio;

  • echo ... | sudo tee ... — создаёт правило udev, чтобы устройства gpiochip принадлежали группе gpio и имели права 0660;

  • sudo udevadm control --reload-rules — перезагружает правила udev;

  • sudo udevadm trigger — применяет правила к уже подключённым устройствам.

Скрипт gpio_setup.sh используется один раз. В него я перенёс строку chmod -R +x robot_pi_service/ из Makefile, чтобы дать права на папку сервиса. После этого можно будет работать с GPIO без sudo из Python.

Дать права скрипту на выполнение:

chmod +x gpio_setup.sh

Запуск скрипта:

./gpio_setup.sh

Чтобы права вступили в силу, нужно выйти из сессии и войти заново. Я просто перезапущу устройство:

sudo shutdown -h now

Проверка групп:

groups

Результат команды:

user_name sudo users gpio

Группа gpio появилась. GPIO и LED готовы к работе — пора писать код.

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

Запускать веб-приложение длинной командой poetry run uvicorn web_robot_control.main:app --host server_ip --port port_number неудобно. Я хочу упростить запуск, написав скрипт запуска для Poetry. В пакете web_robot_control я создал файл start.py, который содержит функцию запуска веб-приложения через uvicorn.

Установка uvicorn с помощью poetry:

poetry add uvicorn

Скрипт запуска:

import uvicorn


def start_app():
    """Функция запуска приложения"""
    
    uvicorn.run(
        'web_robot_control.main:app', 
        host='127.0.0.1',
        port=8000,
        reload=True
    )

Разбор кода:

  • import uvicorn — импорт ASGI-сервера uvicorn, необходимого для запуска FastAPI-приложения;

  • uvicorn.run(...) — вызов метода run для запуска приложения:

  • 'web_robot_control.main:app' — путь к FastAPI-приложению: модуль main внутри пакета web_robot_control, содержащий объект app;

  • host='127.0.0.1' — сервер будет доступен только на локальной машине на ip 127.0.0.1;

  • port=8000 — сервер будет слушать на порту 8000;

  • reload=True — включена автоматическая перезагрузка при изменении исходного кода (удобно для разработки);

  • В дальнейшем ip и port данные будут читаться из .env файла, что более правильно с точки зрения безопасности.

Теперь нужно указать Poetry, где искать функцию запуска.

Изменения в pyproject.toml:

[tool.poetry.scripts]
start_app = "web_robot_control.start:start_app"

Разбор новых строк:

  • [tool.poetry.scripts] — секция в pyproject.toml, которая определяет команды, доступные после установки пакета;

  • start_app — имя команды, которую можно запускать из терминала;

  • "web_robot_control.start:start_app" — путь к функции, которая будет вызвана при запуске команды:

  • web_robot_control — имя Python-пакета;

  • start — модуль внутри пакета;

  • start_app — функция внутри модуля start.py, которая будет вызвана.

Регистрирую команду start_app:

poetry install

Обновляю строку запуска в файле README.md:

Запуск для локальной разработки: poetry run start_app

Теперь я изменю структуру команды, которую фронтенд отправляет на сервер. Для удобства я буду использовать пару ключ–значение, где ключ — название команды, а значение — её статус. Фронтенд будет отправлять строку JSON на сервер. Для команды forward структура будет такой:

  • {"forward": True} — для включения LED;

  • {"forward": False} — для выключения LED.

Изменение в command.js для команды forward:

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

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

  • const forwardOn = { "forward": true }; — создаётся объект forwardOn, который представляет команду движения вперёд. Ключ "forward" установлен в true, что означает активное движение (пока включает LED);

  • startSendingCommand(forwardOn); — вызывается функция startSendingCommand с объектом forwardOn в качестве аргумента. Это запускает процесс отправки через WebSocket;

  • const forwardOff = { "forward": false }; — создаётся объект forwardOff, который представляет команду остановки движения. Ключ "forward" установлен в false, что означает прекращение движения (пока выключает LED);

  • sendCommand(JSON.stringify(forwardOff)); — вызывается функция sendCommand, которая отправляет команду остановки. Объект forwardOff преобразуется в строку JSON перед отправкой, чтобы его можно было передать по сети. Вызов осуществляется при отпускании кнопки.

Теперь мне нужно адаптировать websocket_endpoint() в бэкенде под новую структуру команды и добавить логику:

  • парсить строку JSON, превращая её в dict;

  • не отправлять одну и ту же команду дважды подряд;

  • обрабатывать ошибки при декодировании JSON.

Обновлённый websocket_endpoint():

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

                if name_command in valid_commands:
                    print(command)
                    # отправка команды роботу
                    robot_answer = await command_to_robot(command=command)
                    
                    if robot_answer:
                        # отправка ответа робота на вебсокет фронтенда
                        await websocket.send_text(
                            f'Получена команда: {command}, ответ робота: {robot_answer}'
                        )
                        print(f'Ответ робота: {robot_answer}')
    except WebSocketDisconnect:
        print('WebSocket отключен')  # Todo: для вывода ошибок будет настроен logger
    # Todo: для каждой ошибки написать своё сообщение
    except (WebSocketException, exceptions.InvalidMessage) as err:
        print(f'{err.__class__.__name__}: {err}')
    except JSONDecodeError:
        print('Ошибка при декодировании JSON')

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

  • previous_command = None — переменная для хранения предыдущей команды;

  • if previous_command != command: — проверка, чтобы не отправлять одну и ту же команду повторно;

  • data = loads(command) — парсинг JSON-строки в словарь;

  • name_command = next(iter(data), None) — получение имени команды из словаря;

  • except JSONDecodeError: — обработка ошибки при декодировании JSON;

  • print('Ошибка при декодировании JSON') — вывод сообщения об ошибке.

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

Код робота

Теперь, когда я подключил LED к GPIO и проверил его работу в Linux, я напишу код, который включает светодиод по команде. Команду forward (вперёд) я использую временно для включения LED — в дальнейшем она будет применяться для управления движением робота вперёд.

Для работы с GPIO я создал в пакете robot_pi_service файл gpio_control.py. В нём реализован класс LedLineGpio для управления LED через GPIO.

Мне нужен следующий функционал:

  1. Работа с устройством gpiochip0 и линией line 6 для LED;

  2. Включение и выключение LED.

Код для управления LED:

from gpiod import request_lines, LineSettings
from gpiod.line import Direction, Value


class LedLineGpio:
    """Класс для управления линией GPIO для LED"""
    
    def __init__(self, line: int) -> None:
        self.line = line
        self._request = request_lines(
            '/dev/gpiochip0',
            consumer='led-blinker',
            config={
                line: LineSettings(
                    direction=Direction.OUTPUT,
                    output_value=Value.INACTIVE
                )
            }
        )

    def on(self) -> None:
        """Метод для включения LED"""
        
        self._request.set_value(self.line, Value.ACTIVE)

    def off(self) -> None:
        """Метод для выключения LED"""
        
        self._request.set_value(self.line, Value.INACTIVE)

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

Разбор кода:

  • Импортируется request_lines, LineSettings из библиотеки gpiod, а также Direction и Value из gpiod.line;

  • Создаётся класс LedLineGpio для управления одной линией GPIO, используемой для LED;

  • В конструкторе init принимается номер линии GPIO как параметр;

  • Внутри конструктора вызывается request_lines для запроса доступа к gpiochip0 с указанием потребителя 'led-blinker' (просто название, которое помогает понять кто занял линию);

  • В конфигурации линии указывается направление OUTPUT и стартовое значение INACTIVE;

  • Метод on() активирует линию, устанавливая значение ACTIVE, что включает светодиод;

  • Метод off() выключает линию, устанавливая значение INACTIVE;

  • Метод close() освобождает ресурсы, вызвав release() у запрошенной линии;

  • Класс инкапсулирует управление GPIO в простом API: включение, выключение и освобождение;

  • Используется объектная модель Python для удобного и безопасного контроля над GPIO.

Я использую self._request.release() для освобождения ресурса, так как в request_lines() не реализует протокол контекстного менеджера with:

  • enter() — вызывается при входе в with;

  • exit(exc_type, exc_val, exc_tb) — вызывается при выходе (даже при ошибке).

Теперь осталось реализовать вызов этого функционала в нужных местах функции robot_control_gpio(), которая принимает команды для робота и вызывает соответствующую логику управления.

Я добавлю следующий функционал:

  • Вызов методов включения и выключения LED;

  • Вызов метода освобождения линии GPIO;

  • Парсинг строки JSON.

Обновлённый код robot_control_gpio(): 

import asyncio
from websockets import serve, exceptions
from websockets.legacy.server import WebSocketServerProtocol
from json import loads, JSONDecodeError

from settings import settings
from gpio_control import LedLineGpio


async def robot_control_gpio(websocket: WebSocketServerProtocol):
    """
    Асинхронная функция для управления GPIO робота (через websocket)
    """
    
    try:
        led_line_gpio = LedLineGpio(line=6)
        action = None

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

            match next(iter(data), None):
                case settings.commands_robot.forward:
                    if data.get(settings.commands_robot.forward):
                        action = 'Робот едет вперёд'
                        led_line_gpio.on()
                    else:
                        action = 'Команда вперёд стоп'
                        led_line_gpio.off()
                    print(action)
                    await websocket.send(message=action)
                case settings.commands_robot.backward:
                    action = 'Робот едет назад'
                    print(action)
                    await websocket.send(message=action)
                case settings.commands_robot.left:
                    action = 'Робот едет налево'
                    print(action)
                    await websocket.send(message=action)
                case settings.commands_robot.right:
                    action = 'Робот едет направо'
                    print(action)
                    await websocket.send(message=action)

    except exceptions.ConnectionClosed:
        pass
    except (exceptions.ConnectionClosedOK, exceptions.InvalidMessage, exceptions.InvalidState) as err:
        message_err = f'{err.__class__.__name__}: {err}'
        print(message_err)
        await websocket.send(message=message_err)
    except Exception as err:
        message_err = f'{err.__class__.__name__}: {err}'
        print(message_err)
        await websocket.send(message=message_err)
    finally:
        led_line_gpio.close()

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

  • from gpio_control import LedLineGpio — Импорт класса управления GPIO-линией, который позволяет включать и выключать LED;

  • from json import loads, JSONDecodeError — Импорт функции loads для преобразования JSON-строки в Python-словарь и исключения JSONDecodeError для обработки ошибок при парсинге;

  • led_line_gpio = LedLineGpio(line=6) — Создание экземпляра класса LedLineGpio для управления GPIO-линией с номером 6. Эта линия будет использоваться для индикации движения робота;

  • action = None — Инициализация переменной action, которая будет хранить текстовое описание текущего действия робота (например, “едет вперёд”);

  • data = loads(command) — Преобразование полученной JSON-строки command в Python-словарь data. Это позволяет работать с командами как с обычными ключами и значениями;

  • except JSONDecodeError: — Обработка ситуации, когда входящая строка не является корректным JSON. В этом случае программа продолжает работу, не завершая соединение;

  • match next(iter(data), None): — Использование конструкции match (аналог switch) для сопоставления первого ключа в словаре data с ожидаемыми командами (вперёд, назад и т.д.);

  • Строка next(iter(data), None) — это способ получить первый ключ из словаря data, безопасно и без ошибок, если словарь пуст:
    — iter(data) — создаёт итератор по ключам словаря data;
    — next(..., None) — берёт первый элемент из итератора.
    — Если словарь пуст, возвращает None вместо ошибки StopIteration.

  • if data.get(settings.commands_robot.forward) — Проверка, активирована ли команда “вперёд”. Если значение по ключу forward — True, робот должен начать движение;

  • led_line_gpio.on() — Включение GPIO-линии — например, зажигание светодиода, сигнализирующего о движении робота;

  • led_line_gpio.off() — Выключение GPIO-линии — остановка индикации, когда команда “вперёд” отменена;

  • finally: — Блок, который выполняется в любом случае — даже при ошибках. Здесь вызывается led_line_gpio.close() для освобождения GPIO-ресурса.

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

Функция start():

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

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

    async with serve(handler=robot_control_gpio, host=settings.websocket_host, port=settings.websocket_port):
        # бесконечный цикл
        await asyncio.Future()

Функция run_app():

def run_app():
    """Функция старта приложения"""

    try:
        asyncio.run(start())
    except KeyboardInterrupt:
        print('Выключение сервиса робота для приёма команд.')

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

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

  • print('Выключение сервиса робота для приёма команд.') — информирует о завершении работы сервиса;

  • В дальнейшем будет добавлен логгер для вывода и сохранения отладочной информации.

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

Отладка задержки отправки команды

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

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

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

make run

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

poetry run start_app

Я нажимаю кнопку “вперёд” (стрелка вверх) в веб-приложении и сразу же её отпускаю.

Консоль браузера
Консоль браузера

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

Консоль бекенда
Консоль бекенда
Консоль робота
Консоль робота

Бэкенд отправил роботу две команды — как и положено — и робот их принял.

Для одного быстрого клика 10 отправок на сервер — это слишком часто. Такая частота не имеет смысла, так как из этих 10 команд до робота дойдёт только первая. Остальные будут просто игнорироваться логикой бэкенда. Для удобного управления достаточно, чтобы за один быстрый клик команда отправлялась на сервер один раз. Поэтому я увеличил задержку между отправками в 10 раз — с 10 мс до 100 мс — в коде фронтенда.

Увеличение задержки отправки в command.js:

// Другой код
// Запускаем интервал для повторной отправки команды
commandInterval = setInterval(() => {
    sendCommand(JSON.stringify(command)); // Повторяем отправку команды
}, 100); // Интервал отправки — каждые 100 мс
// Другой код

Теперь, когда я нажимаю кнопку и сразу её отпускаю, фронтенд пытается отправить команду не 10 раз, а один.

Консоль браузера
Консоль браузера

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

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

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

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

Закодированными командами, которыми я хотел заняться ещё в этой статье, займусь немного позже.

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

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


НЛО прилетело и оставило здесь промокод для читателей нашего блога:

-15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.

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