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

Эта статья разделена на две части, и, хотя задача везде одинаковая, способы ее реализации отличаются в зависимости от выбранной платформы.

Часть 1 (Arduino) – первая часть рекомендуется к прочтению, в ней рассказано, что такое USB Host Shield и как его использовать, эта библиотека будет нужна и на ESP32

Часть 2 (ESP32) – вы здесь

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

Какой Bluetooth есть в ESP32

Если коротко, то в Bluetooth, помимо разных версий, есть еще и два отличающихся друг от друга стандарта:

  • Bluetooth Classic (BR/EDR)

  • Bluetooth Low Energy (BLE)

Подробнее о различиях этих стандартов Bluetooth можно почитать, например, в статье Bluetooth Low Energy: подробный гайд для начинающих. Некоторые устройства Bluetooth могут поддерживать только один из стандартов, а другие – оба одновременно (Dual mode Bluetooth).

Варианты ESP32 также бывают разные:

  • С поддержкой BR/EDR + Bluetooth LE v4.2 (те самые Dual mode)

  • С поддержкой только Bluetooth LE v5.0

Первые наиболее распространены, и если у вас уже есть ESP32, то с большой вероятностью в нем будет поддержка Dual mode и поэтому он подойдет для подключения геймпада, абсолютное большинство которых поддерживает исключительно Bluetooth Classic.

Bluetooth API в ESP32

Здесь пора сделать примечание о том, что разработку под ESP32 можно вести с использованием двух различных фреймворков (espruino в этот список я намеренно не включаю):

  • ESP-IDF (Espressif IoT Development Framework) – нативный для ESP32 фреймворк, который можно использовать из командой строки (CLI) либо через удобные плагины для IDE (VSCode и Eclipse)

  • ESP32 Arduino Core – Arduino-совместимая оболочка над ESP-IDF, позволяющая писать код в привычном для Arduino стиле и с использованием привычных функций из стандартных библиотек. ESP32 Arduino Core можно использовать как и с обычной Arduino IDE, так и с VSCode или PlatformIO

Второй вариант, в принципе, позволяет реализовать большинство наиболее частых сценариев для Arduino проектов, однако, следует учитывать, что по функциональности он все равно проигрывает первому варианту – далеко не все, что есть в ESP32, в принципе присутствует в виде стандартного Arduino API, поэтому иногда все равно придется использовать API из ESP-IDF, что приведет к коду в смешанном стиле. Мне, например, не очень симпатизирует такое соседство xTaskCreate и loop в одном проекте.

Поддержка же Bluetooth в ESP32 Arduino Core в основном сводится к классам для работы с BLE сервисами, а для Bluetooth Classic есть только реализация BluetoothSerial.

Поэтому, для полноценной работы с Bluetooth необходимо начинать разработку с использованием ESP-IDF. После привычной Arduino IDE, конечно, требуется некоторое время, чтобы настроить окружение, минимально изучить документацию и привыкнуть к особенностям многозадачной FreeRTOS, но все это совершенно не представляет сложности – ESP-IDF все равно остается достаточно высокоуровневым фреймворком, при использовании которого вам не придется разбираться в тонкостях регистров микропроцессора, а ваш код не будет наполовину состоять из ассемблерных вставок. Документация по ESP-IDF присутствует в достаточном объеме и качественном виде прямо от вендора.

Итак, какие возможности для работы с Bluetooth предоставляет ESP-IDF ?

  • Bluedroid стек, поддерживает как BLE, так и Bluetooth Classic

  • NimBLE стек, поддерживает только BLE

  • VHCI (Virtual Host Controller Interface) – низкоуровневый доступ к контроллеру Bluetooth при помощи HCI команд, это что-то типа стандартизированного API на уровне Bluetooth чипа

Хотя первые два стека и считаются высокоуровневыми Bluetooth API, для их применения вам придется детально разобраться во многих тонкостях Bluetooth, включая разнообразные GATT/GAP/SDP/L2CAP и так далее. Примеры (examples) в ESP-IDF, конечно, есть для всех перечисленных API, но примеры эти настолько абстрактные, что легче от них не становится – готового кода, чтобы взять и быстро подключить Bluetooth устройство к ESP32, нет.

Гугл подсказывает еще несколько возможных вариантов для решения задачи:

  • BlueKitchen BTstack – еще одна реализация BT стека, opensource, есть порты для множества процессорных архитектур (в том числе для ESP32), возможно бесплатное использование в некоммерческих целях. Эта версия Bluetooth API выглядит попроще, чем Bluedroid, есть даже готовый пример с подключением клавиатуры, из которого, наверное, можно сделать и более сложный вариант для геймпада. Такой вариант все равно не выглядит как быстрый

  • Есть проект на GitHub (https://github.com/aed3/PS4-esp32) – 300+ звезд, что неудивительно, поскольку это единственный работающий и готовый к использованию пример подключения DualShock 4 к ESP32. В этом примере присутствует хардкод BT-адреса устройства для подключения и нет полноценной поддержки сопряжения.

  • Второй проект (https://github.com/StryderUK/BluetoothHID) более продвинутый, но репозиторий явно заброшенный, требует замены файлов внутри SDK, и привязан к конкретной, уже старой, версии SDK, вести разработку на которой не хотелось бы. Как поведет себя этот пример с актуальной версией SDK, я не проверял. Разбираться с потенциальными вопросами обратной совместимости версий ESP-IDF – тоже вариант небыстрый.

Все описанные выше варианты реализации по быстроте и удобству вчистую проигрывают той старой библиотеке USB Host Shield 2.0, которая была использована в первой части статьи для классической платы Arduino. Поэтому я задумался, а есть ли способ просто перенести все те наработки по Bluetooth, которые там есть, на ESP32 ?

Портирование Bluetooth стека из USB Host Shield 2.0 на ESP32

Для начала я заглянул в исходники библиотеки. Основная часть исходного кода библиотеки ожидаемо реализует поддержку разнообразных USB устройств – проводные клавиатуры, мышки, флэшки, и так далее. Работа с Bluetooth сводится к подключению специального типа USB устройства – Bluetooth Dongle (BTD). Обработка пакетов данных для BTD завернута в своего рода state-машину, по сути, представляющую упрощенную реализацию Bluetooth стека, тем не менее достаточную для подключения простых BT устройств, включая геймпады.  Главное, что я обнаружил - весь код обмена пакетами данных вдоль и поперек содержит аббревиатуру HCI. Для тех, кто давно работает с Bluetooth на низком уровне это, вероятно, очевидный вывод, но для меня это было находкой, означавшей, что есть возможность взять код от USB Host Shield 2.0 вместе со всей имеющейся там поддержкой BT-устройств, и перенести его на ESP32, просто заменив обмен HCI командами через USB контроллер на API-вызовы VHCI ESP32.

При более детальном изучении исходников обнаружилось пара особенностей:

  1. Bluetooth state-машина сама вычитывает пакеты из входного буфера USB, то есть делает Polling, и сразу же выполняет обработку этих пакетов

  2. Отправка исходящих пакетов в сторону BT устройства в библиотеке происходит непосредственно из state-машины, прямо внутри методов, которые заняты обработкой входящих пакетов.

Как оказалось, такая схема работы без изменений на ESP32 не переносится:

  1. Механизма Polling в ESP32 нет, VHCI интерфейс доставляет входящие пакеты данных в callback-функцию.

  2. Методы VHCI для отправки пакетов не работают, если их вызывать изнутри callback-функций получения данных

Чтобы полностью не переписывать Bluetooth state-машину, при портировании библиотеки на ESP32 я поступил следующим образом:

  1. Callback-функция чтения входящих пакетов от BT не занимается их обработкой – вместо этого все пакеты сохраняются в отдельный ringbuffer (на самом деле в два разных буфера – один для HCI пакетов, второй для ACL пакетов).

  2. Отдельный поток вычитывает пакеты уже из ringbuffer-ов и доставляет их в методы state-машины. В этом случае, когда обработчики начинают вызывать функции отправки данных, получается, что это происходит уже за пределами callback-ов чтения.

В итоге новая схема оказалась вполне работоспособной и быстродействующей. По крайней мере, ESP32 успевает обработать все 1000 пакетов в секунду, которые отправляет DualShock 4 с данными о состоянии кнопок и джойстиков геймпада.

Далее оставались косметические правки вроде замены отладочного логирования на принятую в ESP32 библиотеку esp_log, добавление недостающих #include и #define, после чего новый компонент (это название «библиотеки», принятое в ESP-IDF) был готов.

Компонент BTD_VHCI для ESP-IDF

Скачать готовый компонент можно отсюда - btd_vhci

Если у вас еще не установлен ESP-IDF, то это нужно сделать.

Скрытый текст

Ссылки для быстрого старта с ESP-IDF и VSCode:

Installation - https://github.com/espressif/vscode-esp-idf-extension/blob/master/docs/tutorial/install.md

Basic Use of the Extension - https://github.com/espressif/vscode-esp-idf-extension/blob/master/docs/tutorial/basic_use.md

FreeRTOS Overview - https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/freertos.html#using-freertos

Создание нового пустого проекта делается через F1: ESP-IDF: New Project, далее Choose Template, выбрать ESP-IDF (get-started и шаблон sample_project)

В проекте необходимо сделать необходимые настройки sdkconfig (делаются через F1: ESP-IDF: SDK Configuration Editor)

  • BluetoothD: Enabled

  • Host: Disabled

  • Controller: Enabled

  • Bluetooth controller mode: BR/EDR Only

  • BR/EDR Sync: HCI

  • HCI mode: VHCI

Скрытый текст

Необходимые опции в sdkconfig:

#
# Bluetooth
#
CONFIG_BT_ENABLED=y
CONFIG_BT_CONTROLLER_ONLY=y
CONFIG_BT_CONTROLLER_ENABLED=y
#
# Controller Options
#
CONFIG_BTDM_CTRL_MODE_BR_EDR_ONLY=y
CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN=2
CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN=0
CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_HCI=y
CONFIG_BTDM_CTRL_BR_EDR_SCO_DATA_PATH_EFF=0
CONFIG_BTDM_CTRL_PCM_ROLE_EFF=0
CONFIG_BTDM_CTRL_PCM_POLAR_EFF=0
CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT=y
CONFIG_BTDM_CTRL_LEGACY_AUTH_VENDOR_EVT_EFF=y
CONFIG_BTDM_CTRL_BLE_MAX_CONN_EFF=0
CONFIG_BTDM_CTRL_BR_EDR_MAX_ACL_CONN_EFF=2
CONFIG_BTDM_CTRL_BR_EDR_MAX_SYNC_CONN_EFF=0
CONFIG_BTDM_CTRL_PINNED_TO_CORE_0=y
CONFIG_BTDM_CTRL_PINNED_TO_CORE=0
CONFIG_BTDM_CTRL_HCI_MODE_VHCI=y

То же самое в Menuconfig:

Далее, к проекту нужно добавить зависимость btd_vhci:

  • Способ 1: через IDF Component Manager, выполнить команду idf.py add-dependency -pink0d/btd_vhci (команда запускается в терминале, открыть который можно через F1: ESP-IDF: Open ESP-IDF Terminal)

  • Способ 2: скопировать содержимое репозитория Github в директорию components\btd_vhci внутри проекта и добавить вручную REQUIRES btd_vhci nvs_flash в CMakeLists.txt для компонента main

Для использования C++ классов из библиотеки, следует переименовать main.c в main.cpp и обновить имя файла в CMakeLists.txt компонента main, а к точке входа в приложение добавить модификатор extern "C"

Что еще должно быть в проекте и внутри app_main:

  • Глобальный экземпляр класса для получения данных от BT-устройства. В примере ниже это PS4BT PS4;

  • nvs_flash_init() – инициализации flash памяти для внутренних нужд Bluetooth контроллера ESP32

  • btd_vhci_init() инициализация библиотеки

  • xTaskCreatePinnedToCore(...) запуск основной задачи

  • btd_vhci_autoconnect(...) запуск автоматического соединения с BT устройством. При запуске эта задача пытается найти ранее сохраненный адрес BT устройства, и если такой адрес был ранее сохранен во flash-памяти ESP32, то Bluetooth контроллер начнет ожидать соединения с ним. Если по истечении 30 секунд подключение не будет установлено, или же сохраненного адреса в принципе не было, то Bluetooth контроллер перейдет в режим сопряжения. При успешном сопряжении адрес подключенного устройства будет записан во flash-память

Код основной задачи:

  • Должен обязательно содержать btd_vhci_mutex_lock(); и btd_vhci_mutex_unlock(); при доступе к классам библиотеки, поскольку они не являются потокобезопасными

  • В примере ниже выполняется простой опрос состояния контроллера PlayStation 4 и вывод данных в консоль

Пример в виде готового проекта есть на GitHub - btd_vhci_examples_ESP-IDF

Скрытый текст

Полный код примера:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "nvs_flash.h"

#include "PS4BT.h"
#include "btd_vhci.h"

PS4BT PS4;

bool printAngle, printTouch;
uint8_t oldL2Value, oldR2Value;

static const char *LOG_TAG = "main";

// print controller status
void ps4_print() {
    if (PS4.connected()) {
        if (PS4.getAnalogHat(LeftHatX) > 137 || PS4.getAnalogHat(LeftHatX) < 117 || PS4.getAnalogHat(LeftHatY) > 137 || PS4.getAnalogHat(LeftHatY) < 117 || PS4.getAnalogHat(RightHatX) > 137 || PS4.getAnalogHat(RightHatX) < 117 || PS4.getAnalogHat(RightHatY) > 137 || PS4.getAnalogHat(RightHatY) < 117) {
        ESP_LOGI(LOG_TAG, "L_x = %d, L_y = %d, R_x = %d, R_y = %d",
                    PS4.getAnalogHat(LeftHatX),PS4.getAnalogHat(LeftHatY),
                    PS4.getAnalogHat(RightHatX),PS4.getAnalogHat(RightHatY));
        }

        if (PS4.getAnalogButton(L2) || PS4.getAnalogButton(R2)) { // These are the only analog buttons on the PS4 controller
        ESP_LOGI(LOG_TAG, "L2 = %d, R2 = %d",PS4.getAnalogButton(L2),PS4.getAnalogButton(R2));
        }
        if (PS4.getAnalogButton(L2) != oldL2Value || PS4.getAnalogButton(R2) != oldR2Value) // Only write value if it's different
        PS4.setRumbleOn(PS4.getAnalogButton(L2), PS4.getAnalogButton(R2));
        oldL2Value = PS4.getAnalogButton(L2);
        oldR2Value = PS4.getAnalogButton(R2);

        if (PS4.getButtonClick(PS))
        ESP_LOGI(LOG_TAG, "PS");
        if (PS4.getButtonClick(TRIANGLE)) {
        ESP_LOGI(LOG_TAG, "Triangle");
        PS4.setRumbleOn(RumbleLow);
        }
        if (PS4.getButtonClick(CIRCLE)) {
        ESP_LOGI(LOG_TAG, "Circle");
        PS4.setRumbleOn(RumbleHigh);
        }
        if (PS4.getButtonClick(CROSS)) {
        ESP_LOGI(LOG_TAG, "Cross");
        PS4.setLedFlash(10, 10); // Set it to blink rapidly
        }
        if (PS4.getButtonClick(SQUARE)) {
        ESP_LOGI(LOG_TAG, "Square");
        PS4.setLedFlash(0, 0); // Turn off blinking
        }

        if (PS4.getButtonClick(UP)) {
        ESP_LOGI(LOG_TAG, "UP");
        PS4.setLed(Red);
        } if (PS4.getButtonClick(RIGHT)) {
        ESP_LOGI(LOG_TAG, "RIGHT");
        PS4.setLed(Blue);
        } if (PS4.getButtonClick(DOWN)) {
        ESP_LOGI(LOG_TAG, "DOWN");
        PS4.setLed(Yellow);
        } if (PS4.getButtonClick(LEFT)) {
        ESP_LOGI(LOG_TAG, "LEFT");
        PS4.setLed(Green);
        }

        if (PS4.getButtonClick(L1))
        ESP_LOGI(LOG_TAG, "L1");
        if (PS4.getButtonClick(L3))
        ESP_LOGI(LOG_TAG, "L3");
        if (PS4.getButtonClick(R1))
        ESP_LOGI(LOG_TAG, "R1");
        if (PS4.getButtonClick(R3))
        ESP_LOGI(LOG_TAG, "R3");

        if (PS4.getButtonClick(SHARE))
        ESP_LOGI(LOG_TAG, "SHARE");
        if (PS4.getButtonClick(OPTIONS)) {
        ESP_LOGI(LOG_TAG, "OPTIONS");
        printAngle = !printAngle;
        }
        if (PS4.getButtonClick(TOUCHPAD)) {
        ESP_LOGI(LOG_TAG, "TOUCHPAD");
        printTouch = !printTouch;
        }

        if (printAngle) { // Print angle calculated using the accelerometer only
        ESP_LOGI(LOG_TAG,"Pitch: %lf Roll: %lf", PS4.getAngle(Pitch), PS4.getAngle(Roll));        
        }

        if (printTouch) { // Print the x, y coordinates of the touchpad
            if (PS4.isTouching(0) || PS4.isTouching(1)) // Print newline and carriage return if any of the fingers are touching the touchpad
                ESP_LOGI(LOG_TAG, "");
            for (uint8_t i = 0; i < 2; i++) { // The touchpad track two fingers
                if (PS4.isTouching(i)) { // Print the position of the finger if it is touching the touchpad
                ESP_LOGI(LOG_TAG, "X = %d, Y = %d",PS4.getX(i),PS4.getY(i));          
                }
            }
        }
    }
}

void ps4_loop_task(void *task_params) {
    while (1) { 
        btd_vhci_mutex_lock();      // lock mutex so controller's data is not updated meanwhile
        ps4_print();                // print PS4 status
        btd_vhci_mutex_unlock();    // unlock mutex
        vTaskDelay(1);
    }
}

extern "C" void app_main(void)
{
    esp_err_t ret;

    // initialize flash
    ret = nvs_flash_init();
    if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {
        ESP_ERROR_CHECK(nvs_flash_erase());
        ret = nvs_flash_init();
    }
    ESP_ERROR_CHECK( ret );

    // initilize the library
    ret = btd_vhci_init();
    if (ret != ESP_OK) {
        ESP_LOGE(LOG_TAG, "BTD init error!");
    }
    ESP_ERROR_CHECK( ret );

    // run example code
    xTaskCreatePinnedToCore(ps4_loop_task,"ps4_loop_task",10*1024,NULL,2,NULL,1);

    // run auto connect task
    btd_vhci_autoconnect(&PS4);

    while (1) {       
        vTaskDelay(pdMS_TO_TICKS(100));
    }
    // main task should not return
}

На этом функционал библиотеки в общем-то и заканчивается, оставляя простор, собственно, для DIY-проектов.

Пока что я успел перенести в библиотеку только классы для клавиатуры-мышки (BTHID) и классы для наиболее распространенных контроллеров (PS4, PS5, Xbox), и даже не все из этого получилось протестировать с настоящими устройствами. Конечно, хотелось бы перенести и Serial Port Profile (SPP), но он уже заметно сложнее, а также потребует в целом переосмыслить многопоточность и потоковую безопасность внутри библиотеки.

Использование BTD_VHCI из ESP32 Arduino Core

Использование этого же кода с фреймворком ESP32 Arduino Core, теоретически, тоже возможно. Должен предупредить, что это будет ни разу не легче и не быстрее, чем сразу перейти на ESP-IDF.

Причина в том, что ESP32 Arduino Core состоит из готового набора библиотек, заранее собранных с дефолтным sdkconfig, в котором включен Bludroid стек, а доступа непосредственно к VHCI функциям, похоже, нет. Espressif предоставляет возможность кастомизации конфига через инструмент под названием Library Builder, то есть возможность установить специфичные опции для ESP32 все-таки существует. Чтобы сделать это, кроме установки ESP32 Arduino Core в Arduino IDE, нужно выполнить целую последовательность шагов:

  1. Установить ESP-IDF и Library Builder

  2. Изменить нужные опции в sdkconfig, который используется при сборке библиотек

  3. Собрать свой кастомный билд ESP32 ArduinoCore (как написано в документации, это занимает несколько часов)

  4. Подменить sdk, скачанный через Board Manager в Arduino IDE, на свой кастомный билд

Этот путь я пока что не проходил, поэтому не привожу подробного описания в текущей статье, которая и так уже получилась слишком объемной. Возможно, если к этой теме возникнет интерес, то все-таки проверю совместимость библиотеки с пересобранным  ESP32 Arduino Core и сделаю дополнение уже в виде третьей части статьи.

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


  1. marshallab
    29.08.2024 01:28

    Тема интересная, как и сложная