Закрытый пульт, ESP32 и цифровая археология: расследуем протокол Makeblock MBBTCTR01
Иногда новый проект начинается не с технического задания, а с вещи, которая случайно попала в руки.
В моём случае это был Bluetooth-пульт Makeblock MBBTCTR01. Красивый? Да. Удобный? Более-менее. Два стика, крестовина, плечевые и кнопки с цифрами — всё, что нужно для управления мобильным роботом. А "парочка тележек" с дифференциальным приводом у меня как раз была.

Казалось бы, задача несложная: подключить пульт к ESP32, прочитать стики и передать команды моторам. Пульт не обнаружился как стандартный HID-геймпад и не предоставил ожидаемый HID-профиль, а описание внутреннего протокола в пользовательской документации отсутствовало.
Пришлось последовательно разобраться:
какой протокол;
как подключить;
что находится внутри передаваемых пакетов;
как не перепутать начало и конец сообщения;
как использовать полученные данные для управления роботом;
как превратить ESP32-C3 в отдельный BLE–UART-адаптер для Arduino.
В итоге получился не только работающий робот, но и открытый репозиторий:
https://github.com/stepanburmistrov/makeBlock_pult
В этой статье важен не только результат. Гораздо интереснее сам маршрут: где искать сведения о незнакомом устройстве, как отделять факт от гипотезы, как проверять каждый следующий слой минимальным экспериментом и что делать, когда по точному названию пульта поиск почти ничего полезного не возвращает.
Зацепка №1: сначала выяснить, что вообще попало в руки
Начинать поиск со слов белый пульт Makeblock — почти бесполезно.

Однако, именно эта находка пробуждает жадность... ну неужели такой девайс просто будет валяться на полке...
Устройство нужно идентифицировать максимально точно, поэтому первой полезной находкой стала не лицевая панель, а наклейка в батарейном отсеке.

На ней нашлись сразу два хороших поисковых ключа:
Model: MBBTCTR01 FCC ID: 2AH9Q-MBBTCTR01
Почему полезны оба? Название модели ведёт к магазинам, фотографиям и официальной странице пульта. FCC ID ведёт в карточку сертификации устройства, где лежат руководство, фотографии корпуса и электроники, частотный диапазон и другие документы, которые производитель подавал для сертификации.
Первый результат исследования оказался одновременно полезным и разочаровывающим.
Удалось подтвердить:
это именно Makeblock Bluetooth Controller;
используется Bluetooth 4.0;
пульт предназначен для прямого сопряжения с роботами и Bluetooth-модулями Makeblock;
для сопряжения нужно поднести его к модулю и удерживать кнопку Bluetooth до быстрого мигания индикатора;
пульт действительно передаёт стики и допускает одновременное нажатие кнопок.
Но ни имени BLE-устройства, ни UUID, ни структуры пакета в пользовательской инструкции нет. FCC-документы тоже описывают радио и аппаратную часть, а не прикладной протокол.
Это нормальная ситуация. Документация не дала готового ответа, зато сузила пространство поиска и подсказала следующий вопрос: что именно пульт считает совместимым Bluetooth-модулем?
Небольшое отступление: что такое BLE и почему здесь нет COM-порта
Перед продолжением расследования стоит собрать минимальную модель BLE. Без неё имя Makeblock_LE, роли устройств и набор UUID выглядят как магические строки, которые нужно просто скопировать.
Bluetooth Classic и Bluetooth Low Energy — не одно и то же
Под общим названием Bluetooth скрываются разные способы обмена данными.
Bluetooth Classic знаком по наушникам, колонкам, некоторым старым геймпадам и модулям HC-05. Для самодельной электроники особенно удобен профиль SPP: после сопряжения устройство выглядит как последовательный порт, и программа передаёт обычный поток байтов.
Bluetooth Low Energy, или BLE, появился для датчиков, маяков, носимой электроники и других устройств, которым важны небольшое энергопотребление, короткие обмены и длительная работа от батарейки. В BLE обычно нет универсального беспроводного COM-порта. Вместо него приложение работает с именованными данными в GATT-базе.
MBBTCTR01 использует именно BLE. Поэтому BluetoothSerial.h и примеры SPP для ESP32 здесь не подходят, даже несмотря на слово Bluetooth в названии обоих подходов.
Как два BLE-устройства находят друг друга
До соединения одно устройство периодически передаёт короткие объявления — advertising packets. В них могут находиться адрес, имя, часть списка сервисов и служебные признаки. Это похоже на:
«Я здесь. Меня зовут Makeblock_LE. У меня есть такие сервисы».
Другое устройство выполняет scan, принимает объявления и решает, к кому подключиться.
В нашей задаче последовательность такая:
ESP32 публикует advertising → пульт сканирует эфир → пульт находит совместимый профиль → пульт инициирует соединение
После установления соединения advertising обычно прекращается. Поэтому после отключения пульта ESP32 должна запустить его заново.
Полный путь одного сеанса можно разложить на пять шагов:
Шаг | Что происходит | Что можно наблюдать |
|---|---|---|
1 | ESP32 передаёт advertising |
|
2 | пульт сканирует эфир | его индикатор быстро мигает |
3 | пульт инициирует соединение | вызывается |
4 | пульт обнаруживает GATT-сервисы и характеристики | внешне почти ничего не видно |
5 | пульт записывает кадры в характеристику RX | вызывается |
Эта таблица заодно показывает, почему постоянный синий индикатор ещё не доказывает передачу команд. Он подтверждает третий шаг, а нас интересует ещё и пятый.
Central и peripheral
Термины central и peripheral описывают поведение во время установления соединения, а не физический смысл устройства.
peripheralпубликует advertising и ждёт входящего соединения;centralсканирует эфир и инициирует соединение.
Пульт вполне может быть central, а ESP32 — peripheral. Слова «главный» и «периферийный» здесь легко вводят в заблуждение.
Есть ещё термины GATT client и GATT server. Они описывают уже работу с данными:
GATT server хранит дерево атрибутов;
GATT client обнаруживает их и выполняет чтение, запись или подписку.
Чаще peripheral одновременно является GATT server, но это не обязательное правило протокола. В этом проекте ESP32 совмещает эти две роли: BLE peripheral и GATT server.
Ещё одна терминологическая тонкость: в бытовых инструкциях словом «сопряжение» часто называют весь процесс подключения. В спецификации BLE connection, pairing и bonding — разные вещи. Соединение создаёт канал обмена; pairing согласует параметры безопасности и ключи; bonding сохраняет ключи для следующих сеансов. Нашей ESP32 достаточно установить соединение и предоставить ожидаемый GATT-профиль — PIN-код и сохранение ключей не используются.
GATT: шкаф, папки и ячейки
GATT-базу удобно представить как шкаф:
BLE-устройство └── Service ├── Characteristic TX │ └── Descriptor └── Characteristic RX
serviceобъединяет функции одного назначения;characteristicхранит или передаёт конкретное значение;descriptorсодержит дополнительные настройки характеристики.
У каждой сущности есть UUID — идентификатор, по которому клиент понимает, с чем работает. Полная форма знакомых нам UUID выглядит так:
0000FFE3-0000-1000-8000-00805F9B34FB
В статье и коде она часто сокращается до FFE3. Но одного UUID недостаточно: у характеристики есть ещё свойства, определяющие разрешённые операции.
Свойство | Что делает |
|---|---|
| клиент запрашивает текущее значение |
| клиент записывает данные и получает подтверждение уровня GATT |
| клиент записывает без отдельного ответа, быстрее и с меньшими накладными расходами |
| server сам отправляет обновление подписавшемуся клиенту |
| похоже на notify, но клиент подтверждает доставку |
В BLE-UART-профиле обычно есть направление RX с разрешением записи и направление TX с уведомлениями. Это всё ещё не настоящий UART, а соглашение: байты, записанные в характеристику, программно пересылаются дальше как поток.
Где заканчивается BLE и начинается протокол пульта
В проекте есть два независимых уровня:
Уровень | За что отвечает |
|---|---|
BLE/GATT | обнаружение устройств, соединение и доставка массива байтов через характеристику |
протокол MBBTCTR01 | смысл байтов: заголовок, оси, кнопки и контрольная сумма |
BLE не знает, что байт 0x80 означает центр стика, а бит 0x01 — кнопку 1. Для него это просто содержимое операции WRITE. И наоборот: структура кадра FF 55 ... не объясняет, через какой радиоканал этот кадр был доставлен.
Такое разделение помогает отладке. Если нет события onConnect(), проблема на уровне BLE. Если соединение есть, но callback записи молчит, нужно проверять GATT-профиль и свойства характеристик. Если байты приходят, но оси определяются неверно, BLE уже ни при чём — ошибка находится в прикладном парсере.
Подробное устройство BLE описано в официальном Bluetooth LE Primer, а рабочий пример GATT-сервера есть в документации Arduino Core for ESP32.
Зацепка №2: кто кого ищет в BLE
В BLE важно не перепутать две роли:
Роль | Что обычно делает |
|---|---|
| публикует advertising; после соединения предоставляет GATT-сервисы и характеристики |
| сканирует эфир, выбирает peripheral и инициирует соединение |
Я сначала мысленно отнёс пульт к обычным геймпадам: ESP32 должна найти его и подключиться. Но официальная инструкция описывает другую последовательность:
включить робота с Bluetooth-модулем;
включить пульт — индикатор мигает медленно;
поднести пульт к модулю;
удерживать кнопку Bluetooth до быстрого мигания;
дождаться автоматического сопряжения.
Из этого следует проверяемая гипотеза: пульт не ждёт, пока его найдут. Во время сопряжения он сам сканирует эфир и подключается к подходящему модулю. Значит, пульт работает как central, а фирменный модуль — как peripheral.
Следовательно, ESP32 тоже должна стать peripheral и изобразить не пульт, а приёмный модуль Makeblock.
Эта смена точки зрения — важнейшая часть всего расследования. Можно долго писать BLE-сканер на ESP32 и безуспешно искать контроллер, хотя сканером в этой паре является сам контроллер.
Зацепка №3: какое имя и какие сервисы ожидает пульт
Теперь нужен портрет фирменного приёмника. Получить его можно двумя способами.
Вариант A: посмотреть на настоящий модуль
Если под рукой есть mBot или отдельный Bluetooth-модуль Makeblock, самый надёжный путь — включить его и открыть на телефоне BLE-анализатор, например nRF Connect.
Вариант B: восстановить GATT-профиль по открытым следам
Фирменного модуля может не быть — у меня была ESP32 и отдельный пульт. Тогда полезно искать уже не модель контроллера, а предполагаемый приёмник:
"Makeblock_LE" "Makeblock_LE" FFE3 Makeblock BLE service UUID Makeblock Bluetooth module GATT
Поиск по имени Makeblock_LE приводит к сторонним проектам, где люди подключали компьютеры и Raspberry Pi к mBot. В одном из таких разборов BLE-интерфейса mBot показано то же GATT-дерево: сервис FFE1, характеристика уведомлений FFE2 и характеристика записи FFE3.
Это ещё не доказательство того, что MBBTCTR01 использует именно этот профиль. Это обоснованная гипотеза, которую можно проверить, воспроизведя профиль на ESP32 и запустив сопряжение.
Есть еще второй вариант FFE0/FFE1
В старых BLE-UART-модулях широко встречается другой профиль:
Service: 0000FFE0-0000-1000-8000-00805F9B34FB I/O: 0000FFE1-0000-1000-8000-00805F9B34FB
Он характерен для семейства HM-10 и совместимых прозрачных UART-модулей. Одна характеристика FFE1 обычно совмещает чтение, запись и уведомления. Такой вариант встречается в старом оборудовании и многочисленных BLE-UART-примерах.
Профиль | Откуда появилась гипотеза | Что создаёт ESP32 |
|---|---|---|
| GATT фирменного |
|
| старые совместимые BLE-UART-модули | одна двунаправленная |
Для совместимости ESP32 публикует оба профиля одновременно. Такой эксперимент подтверждает, что пульт принимает комбинированный профиль, но сам по себе не показывает, через какую из двух характеристик он передаёт данные. Для строгой проверки потребовались бы две отдельные прошивки: только FFE1/FFE2/FFE3 и только FFE0/FFE1.
Эксперимент №1: добиться соединения и больше ничего
Первая прошивка ничего не знает о пакетах, стиках и моторах. Она только создаёт имя, оба GATT-профиля и обработчики соединения
https://github.com/stepanburmistrov/makeBlock_pult/tree/main/01_ble_connection
Сокращённо идея выглядит так:
BLEDevice::init("Makeblock_LE"); BLEServer *server = BLEDevice::createServer(); server->setCallbacks(&serverCallbacks); BLEService *newService = server->createService(SERVICE_NEW); newService->createCharacteristic( CHAR_NEW_RX, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR ); newService->start(); BLEAdvertising *advertising = BLEDevice::getAdvertising(); advertising->addServiceUUID(SERVICE_NEW); advertising->addServiceUUID(SERVICE_LEGACY); advertising->start();
Проверка выполняется снизу вверх.
Проверка 1: ESP32 действительно публикует нужный профиль
Пока пульт выключен, телефон с BLE-анализатором должен увидеть:
имя
Makeblock_LE;сервис
FFE1;сервис
FFE0;внутри них созданные характеристики и правильные свойства.

Если устройства в списке нет нужно проверять запуск BLE-сервера, advertising и выбранную плату ESP32.
Проверка 2: пульт считает ESP32 совместимым модулем
Телефон отключается, пульт переводится в режим сопряжения. Последовательность та же, что в фирменной инструкции:

Если индикатор перестал мигать и горит синим постоянно, пульт нашёл нашу ESP32 и считает соединение установленным. Гипотеза о ролях central/peripheral подтверждена.
Проверка 3: соединение видит сама ESP32
Callback сервера выставляет флаг, а основной цикл печатает событие:

Теперь есть два независимых признака одного события:
постоянный синий индикатор со стороны пульта;
onConnect()и строка в Serial со стороны ESP32.
При выключении пульта должна появиться строка:

Перезапуск advertising важен: после разрыва ESP32 снова должна стать видимой для следующего подключения.
Эксперимент №2: соединение есть, а данные идут?
Успешное BLE-соединение ещё не означает, что выбранная характеристика верна. Пульт может подключиться, но писать не туда — или вообще ждать дополнительной инициализации.
Следующая прошивка добавляет callback записи и печатает все полученные байты в HEX, ничего не пытаясь декодировать:
class ControllerWriteCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *characteristic) override { auto value = characteristic->getValue(); if (value.length() == 0) return; const uint8_t *data = reinterpret_cast<const uint8_t *>(value.c_str()); printHex(data, value.length()); } };
В мониторе порта появились строки:
BLE WRITE #1, 10 byte(s): FF 55 80 00 80 00 80 00 80 00 BLE WRITE #2, 10 byte(s): FF 55 80 00 80 00 FF 00 80 7F
Это уже очень радостное событие-подтверждение:
пульт установил соединение именно с нашей ESP32;
он нашёл характеристику записи;
данные идут без дополнительного запроса;
состояние передаётся короткими бинарными пакетами;
в начале повторяется
FF 55.при нажатии кнопок и движении стиков биты меняются
Здесь важно не спешить и не объявлять каждую BLE-запись отдельным пакетом. BLE-callback сообщает границы полученных фрагментов, а не гарантированные границы прикладного протокола. Один логический пакет может быть разделён, а в одном фрагменте теоретически может находиться несколько сообщений.
Эксперимент №3: меняем один орган управления за раз
Для исследования неизвестного пакета хорошо работает принцип: один эксперимент — одна переменная.
Сначала фиксируется контрольный кадр с отпущенными кнопками и стиками в центре. Затем меняется только один орган управления и сравниваются строки.
Эксперимент | Полученный кадр | Изменение |
|---|---|---|
всё отпущено |
| исходное состояние |
правый стик вправо |
| байт 6: |
кнопка |
| байт 5: |
крестовина вверх |
| байт 7: |
Уже из четырёх кадров появляются гипотезы:
FF 55— постоянный заголовок;четыре значения
80похожи на центры четырёх аналоговых осей;байты между осями похожи на группы кнопок;
последний байт зависит от всех предыдущих и может быть контрольной суммой.
Дальше процедура механическая:
отклонить левый стик только по X;
вернуть его в центр;
отклонить его только по Y;
повторить для правого стика;
нажимать кнопки по одной;
нажимать две кнопки из одной группы одновременно;
проверить минимальные и максимальные положения осей;
для каждого кадра пересчитать возможную сумму.
Особенно полезны одновременные нажатия. Если отдельные кнопки дают 01, 02, 04, а две вместе — 03 или 05, перед нами почти наверняка битовая маска.
Зацепка №4: искать не название устройства, а знакомый почерк
На этом месте можно было полностью восстановить пакет экспериментально. Но исследование стало гораздо интереснее после поиска по репозиториям Makeblock.
В официальной библиотеке производителя нашёлся файл MePS2.cpp. Название сбивает с толку: речь идёт о более старом контроллере в стиле PS2, а не о MBBTCTR01. Сам код датирован 2016 годом, тогда как FCC-заявка MBBTCTR01 появилась в 2018-м.
Но внутри обнаружился буквально тот же почерк:
заголовок: FF 55 оси: buffer[2], buffer[4], buffer[6], buffer[8] кнопки: buffer[3], buffer[5], buffer[7] checksum: buffer[2] + ... + buffer[8] длина кадра: 10 байт
Даже преобразование осей совпало: значение центра 128 вычитается из сырого байта, результат умножается на два, а вертикальные оси инвертируются.
Старая библиотека не заменила эксперимент, а подтвердила его. Заодно она показала важный приём: производитель может переиспользовать протокол в нескольких поколениях устройств, меняя корпус, названия кнопок и маркетинговое имя. Поэтому искать нужно не только точную модель, но и предыдущие продукты той же экосистемы.
В MePS2.cpp правые кнопки называются TRIANGLE, XSHAPED, SQUARE и ROUND, тогда как на MBBTCTR01 написаны 1, 3, 4, 2. Их соответствие всё равно пришлось проверить нажатием каждой кнопки. Это хороший пример того, как открытый код и физический эксперимент дополняют друг друга.
Собираем доказательства в окончательный формат пакета
Теперь структура подтверждается сразу двумя независимыми способами:
наблюдением реальных BLE-кадров MBBTCTR01;
старым официальным декодером Makeblock.

Индекс | Содержимое | Как установлено |
|---|---|---|
0 |
| повторяется во всех кадрах, подтверждён |
1 |
| повторяется во всех кадрах, подтверждён |
2 | левый стик X | меняется только при движении |
3 | плечевые кнопки, Bluetooth, нажатие левого стика | одиночные и комбинированные нажатия |
4 | левый стик Y | меняется только при движении |
5 | кнопки | одиночные и комбинированные нажатия |
6 | правый стик X | меняется только при движении |
7 | крестовина, меню, нажатие правого стика | одиночные и комбинированные нажатия |
8 | правый стик Y | меняется только при движении |
9 | контрольная сумма | совпадает с суммой байтов 2–8 по модулю 256 |
Кнопки оказались тремя битовыми масками
Байт 3:
Маска | Кнопка |
|---|---|
|
|
|
|
|
|
|
|
| Bluetooth |
| нажатие левого стика |
Байт 5:
Маска | Кнопка |
|---|---|
|
|
|
|
|
|
|
|
|
|
Байт 7:
Маска | Кнопка |
|---|---|
| вверх |
| вниз |
| влево |
| вправо |
| меню |
| нажатие правого стика |
Порядок кнопок 1–4 внутри маски не совпадает с числовым порядком. Это как раз тот случай, когда эксперимент «нажимаем одну кнопку и смотрим один байт» надёжнее интуиции.
Проверка отдельной кнопки сводится к обычной битовой операции:
if (face & 0x01) next.buttons |= BTN_1; if (face & 0x02) next.buttons |= BTN_3; if (face & 0x04) next.buttons |= BTN_4; if (face & 0x08) next.buttons |= BTN_2;
Контрольная сумма
Заголовок FF 55 в сумму не входит. Складываются семь следующих байтов, а переполнение uint8_t оставляет младшие восемь бит:
uint8_t checksum = 0; for (size_t i = 0; i < 7; ++i) { checksum = static_cast<uint8_t>(checksum + payload[i]); } if (checksum != payload[7]) { return; }
Формулой это можно записать так:
checksum = (byte2 + byte3 + ... + byte8) % 256
Приводим оси к удобному диапазону
Сырые значения находятся в диапазоне 0...255, центр — около 128. Для управления удобнее симметричный диапазон -255...255:
static int16_t decodeAxis(uint8_t raw, bool invert) { int16_t value = 2 * (static_cast<int16_t>(raw) - 128); if (invert) value = -value; if (value <= -254) value = -255; if (value >= 254) value = 255; if (abs(value) <= AXIS_DEAD_ZONE) value = 0; return value; }
Вертикальные оси инвертируются, чтобы стик вверх давал положительное значение. Небольшая мёртвая зона убирает дрожание около центра.
После преобразования логика становится естественной:
Положение | Значение |
|---|---|
центр |
|
вправо |
|
влево |
|
вверх |
|
вниз |
|
Почему понадобился потоковый парсер
Раз мы не считаем один callback одним пакетом, нужен автомат, который работает с произвольным потоком байтов.
У него три состояния:
enum ParseState : uint8_t { WAIT_FF, WAIT_55, READ_PAYLOAD };
Логика простая:
ждать
0xFF;после него ждать
0x55;собрать восемь байтов полезной части;
проверить сумму;
декодировать;
снова искать заголовок.
void feedByte(uint8_t value) { switch (state) { case WAIT_FF: if (value == 0xFF) state = WAIT_55; break; case WAIT_55: if (value == 0x55) { payloadIndex = 0; state = READ_PAYLOAD; } else if (value != 0xFF) { state = WAIT_FF; } break; case READ_PAYLOAD: payload[payloadIndex++] = value; if (payloadIndex == sizeof(payload)) { decodePacket(payload); payloadIndex = 0; state = WAIT_FF; } break; } }
Такой парсер переживает мусор перед заголовком, разделение пакета между callback-ами и несколько пакетов подряд.
Наконец-то понятный Serial
https://github.com/stepanburmistrov/makeBlock_pult/tree/main/03_decode_protocol_serial
Вывод в Serial:
#25 | RAW=FF 55 80 00 80 01 FF 01 80 81 | LX=0 LY=0 RX=255 RY=0 | BTN=1,UP
Видно сразу всё:
номер корректного пакета;
исходные байты;
четыре декодированные оси;
одновременно нажатые кнопки.
Отдельно считаются пакеты с неверной контрольной суммой.
Ошибки, которые встретились по дороге
ringbuf_type_t has not been declared
Причиной оказалась старая библиотека ESP32_BLE_Arduino, установленная вручную в Documents/Arduino/libraries. Она перекрывала BLE-библиотеку из современного пакета плат Espressif.
Решение — удалить старую внешнюю библиотеку. Отдельно устанавливать BLE для ESP32 сейчас не требуется.
String has no member named data
В разных версиях Arduino Core возвращаемый тип getValue() отличался. В текущем варианте используется совместимый доступ:
reinterpret_cast<const uint8_t *>(value.c_str())
Пульт подключён, но робот не двигается
Постоянный синий индикатор подтверждает только BLE-соединение. Он не гарантирует приём пакетов с правильной структурой и суммой. Поэтому полезно отдельно пройти этапы «соединение → сырые байты → декодирование» и только затем подключать моторы.
От пакетов к роботу
Когда оси стали предсказуемыми, оставалось подключить их к уже готовому шасси.
https://github.com/stepanburmistrov/makeBlock_pult/tree/main/04_motor_control

У робота четыре мотор-редуктора и дифференциальный привод. Каждый канал H-моста управляется двумя входами.

Распиновка получилась такой:
Мотор | Вперёд | Назад |
|---|---|---|
левый | GPIO 25 | GPIO 33 |
правый | GPIO 27 | GPIO 26 |
Все команды подаются через analogWrite():
static void setMotor( uint8_t forwardPin, uint8_t reversePin, int16_t command, bool invert ) { command = static_cast<int16_t>( constrain(static_cast<int>(command), -255, 255) ); if (invert) command = -command; const uint8_t pwm = commandToPwm(command); if (pwm == 0) { analogWrite(forwardPin, 0); analogWrite(reversePin, 0); } else if (command > 0) { analogWrite(reversePin, 0); analogWrite(forwardPin, pwm); } else { analogWrite(forwardPin, 0); analogWrite(reversePin, pwm); } }
Перед изменением направления противоположный вход обнуляется.
Для управления я выбрал правый стик:
RY— газ;RX— поворот.
Простейший микшер обычно складывает и вычитает газ и поворот:
left = throttle + steering right = throttle - steering
Но при сильном отклонении одно колесо может изменить направление. Робот начнёт активно вращаться, хотя оператор ожидает движение по дуге.
Мне хотелось другого поведения: во время движения внешнее колесо сохраняет скорость, внутреннее замедляется, но не вращается назад.
Обозначим:
T = RY S = RX K = TURN_SENSITIVITY
Величина замедления:
reduction = |T| × (|S| / 255) × K
При движении вперёд и повороте вправо:
leftMotor = T rightMotor = T - reduction
При повороте влево замедляется левое колесо. Для заднего хода логика учитывает знак газа.
Если газ находится в мёртвой зоне, горизонтальная ось включает разворот на месте:
leftMotor = RX × K rightMotor = -RX × K
Чувствительность регулируется одной константой:
static const float TURN_SENSITIVITY = 0.75f;
0.3 даёт широкие дуги, 1.0 позволяет полностью остановить внутреннее колесо при максимальном отклонении.

Мотор не обязан стартовать с единицы
Ещё одна практическая особенность: реальный мотор не начинает вращаться от analogWrite(pin, 1). Нужно преодолеть трение редуктора, нагрузку шасси и момент покоя.
Для этого робота уверенный старт начинался примерно со 100:
static const uint8_t MIN_MOTOR_PWM = 100;
Нулевая команда остаётся нулевой, а рабочий диапазон стика преобразуется в 100...255. Так сохраняется остановка в центре и исчезает большая бесполезная область, в которой ШИМ уже есть, а движения ещё нет.
Перепутанные стороны и направления
На раннем варианте вперёд и назад работали правильно, а поворот был зеркальным. Причина оказалась не в формуле — левый и правый каналы были назначены наоборот.
Если отдельный двигатель установлен с обратной полярностью, для него есть программная инверсия:
static const bool INVERT_LEFT_MOTOR = false; static const bool INVERT_RIGHT_MOTOR = false;
Это безопаснее, чем исправлять направление дополнительными знаками в разных частях микшера.
Fail-safe — обязательная часть радиоуправления
BLE-событие disconnect полезно, но полагаться только на него нельзя. Связь может формально сохраняться, хотя новые управляющие пакеты уже не приходят.
После каждого корректного пакета записывается время:
lastValidPacketMs = millis();
Если новых данных нет более 350 мс, прошивка:
обнуляет четыре оси;
сбрасывает кнопки;
устанавливает
online = false;записывает
0во все четыре моторных выхода.
ControllerState pad = readController(); if (pad.online && millis() - lastPacketCopy > FAILSAFE_MS) { setControllerOffline(); pad = readController(); } useController(pad);
Пакет с неверной контрольной суммой таймер не обновляет.
Перед первым напольным заездом я обязательно проверяю fail-safe с вывешенными колёсами: отклоняю стик, выключаю пульт и убеждаюсь, что оба мотора быстро останавливаются.
Ещё и ESP32-C3
Прямое управление моторами с ESP32 работает, но жёстко связывает BLE-код с конструкцией конкретного робота. Стоит поменять драйвер, распиновку или основной контроллер — и прошивку приходится переделывать.

А если роботом управляет Arduino Nano, Mega, STM32 или вообще какая-нибудь собственная плата без Bluetooth?
Логичнее разделить обязанности:
ESP32-C3 общается с пультом по BLE;
проверяет и разбирает недокументированный протокол Makeblock;
основной контроллер получает по UART уже готовые значения осей и кнопок;
программа робота ничего не знает о BLE-сервисах, характеристиках и битовых масках.
В моём случае мостом стала компактная ESP32-C3 SuperMini, а основным контроллером — Arduino Nano.

Подключение ESP32-C3 к Arduino Nano
Для передачи данных достаточно двух проводов:
ESP32-C3 GPIO4 TX → Arduino Nano D0/RX ESP32-C3 GND → Arduino Nano GND
Параметры UART:
57600 бод, 8N1
Передача односторонняя: ESP32-C3 только отправляет данные, поэтому TX/D1 Arduino к ней не подключается.
Здесь есть важная особенность. Контакт D0/RX Nano используется не только программой, но и загрузчиком. Поэтому перед прошивкой Arduino провод GPIO4 → D0/RX необходимо отсоединить, а после загрузки подключить обратно.
Старые варианты с SoftwareSerial, D2/D3 и скоростью 9600 бод в этой версии больше не используются. Nano принимает данные через аппаратный Serial.
Собственный текстовый протокол MB1
Можно было передавать бинарную C-структуру. Она занимает меньше места, но зависит от размеров типов, порядка полей, выравнивания памяти и архитектуры контроллера.
Для учебных и экспериментальных роботов мне хотелось получить формат, который:
можно увидеть обычным терминалом;
легко записать в лог;
несложно разобрать на Arduino, STM32, Raspberry Pi или компьютере;
можно расширять без полной переделки приёмника.
Так появился текстовый протокол MB1:
MB1|SEQ=42|ONLINE=1|LX=0|LY=0|RX=127|RY=255|UP=0|DOWN=0|LEFT=0|RIGHT=0|B1=1|B2=0|B3=0|B4=0|L1=0|L2=0|R1=1|R2=0|LEFT_STICK=0|RIGHT_STICK=0|PLUS=0|MENU=0|BT=0*27
Один пакет занимает одну строку и заканчивается символом \n.
В строке явно передаётся всё состояние пульта:
версия протокола;
порядковый номер кадра
SEQ;признак актуальной связи
ONLINE;четыре оси
LX,LY,RX,RY;крестовина;
кнопки
1–4;плечевые кнопки;
нажатия на стики;
служебные кнопки;
контрольная сумма CRC-8.
Для каждой из 17 кнопок всегда передаётся отдельное значение:
1 — кнопка нажата; 0 — кнопка не нажата.
Приёмнику не нужно запоминать события нажатия и отпускания. Каждая корректная строка полностью описывает состояние пульта в текущий момент.
Как Arduino принимает строку
Arduino Nano имеет всего 2 Кбайт оперативной памяти, поэтому использовать динамические объекты String в постоянно работающем UART-парсере не хотелось.
Вместо этого приёмник использует обычный символьный буфер:
static char lineBuffer[300]; static size_t lineLength = 0; static bool lineOverflow = false;
Байты последовательно читаются из аппаратного Serial:
while (Serial.available() > 0) { char value = Serial.read(); if (value == '\n') { acceptLine(); } else if (value != '\r') { if (lineLength < sizeof(lineBuffer) - 1) { lineBuffer[lineLength++] = value; } else { lineOverflow = true; } } }
Перевод строки означает окончание пакета. Если строка оказалась длиннее буфера, она полностью отбрасывается. Это защищает память Nano от выхода за границы массива.
Использование статического массива даёт ещё одно преимущество: программа не выполняет постоянные выделения и освобождения памяти, а значит, не фрагментирует небольшую оперативную память ATmega328P.
Проверка принятого пакета
Полученная строка не применяется к роботу сразу. Сначала Arduino выполняет несколько проверок:
ищет в конце строки символ
*и две шестнадцатеричные цифры CRC;вычисляет CRC-8 с полиномом
0x07;проверяет сигнатуру
MB1;разбирает пары вида
ИМЯ=ЗНАЧЕНИЕ;проверяет наличие всех обязательных полей;
проверяет диапазон осей
-255...255;принимает для кнопок только
0или1.
Во время разбора данные записываются не прямо в рабочее состояние pad, а во временную структуру:
ControllerState next;
Только после успешного прохождения всех проверок выполняется:
pad = next; lastControllerLineMs = millis();
Это важная деталь: обрезанный или повреждённый пакет не может частично изменить управление. Робот либо получает целиком проверенное новое состояние, либо продолжает работать с предыдущим корректным кадром до срабатывания тайм-аута.
Наличие всех полей контролируется битовой маской seen. Каждому ожидаемому полю соответствует собственный бит. После разбора маска должна полностью совпасть с REQUIRED_FIELDS.
При этом неизвестные дополнительные поля можно игнорировать. Значит, в будущем протокол можно расширить, не ломая старые приёмники.
Зачем нужен SEQ
Поле SEQ увеличивается при отправке каждой строки:
SEQ=41 SEQ=42 SEQ=43
Для управления двигателями оно напрямую не требуется, но сильно помогает при диагностике. По последовательности кадров можно понять:
не остановилась ли передача;
теряются ли отдельные строки;
перезапускалась ли ESP32-C3;
действительно ли в UART продолжают поступать новые данные.
Что происходит при ONLINE=0
Если ESP32-C3 потеряла актуальные данные пульта, она передаёт корректный кадр с:
ONLINE=0
При разборе такого кадра Arduino сохраняет номер SEQ, но принудительно очищает всё остальное:
if (!next.online) { uint32_t sequence = next.sequence; next = ControllerState{}; next.sequence = sequence; }
В результате все оси становятся равны нулю, а все кнопки считаются отпущенными. Даже если в повреждённой или ошибочной строке рядом с ONLINE=0 окажутся ненулевые команды, они не попадут в управление.
От структуры пульта к движению робота
После разбора программа работает с обычной структурой:
if (!pad.online) { stopRobot(); return; }
Если связь активна, правый стик передаётся в микшер дифференциального привода:
int16_t left = 0; int16_t right = 0; mixRightStick(pad, left, right);
Здесь:
RY — движение вперёд и назад; RX — поворот.
При движении поворот выполняется по дуге: скорость внутреннего колеса уменьшается, но его направление не меняется.
Например, при движении вперёд и повороте вправо:
левое колесо = RY; правое колесо = RY - уменьшение скорости.
Если RY находится в центре, отклонение RX разворачивает робот на месте — колёса вращаются в разные стороны.
Чувствительность поворота задаётся отдельно:
static const float TURN_SENSITIVITY = 0.75f;
Чем меньше значение, тем шире дуга. При значении 1.0 внутреннее колесо может полностью остановиться при максимальном отклонении стика.

Минимальный ШИМ
Моторы этого робота не начинают вращаться при слишком маленьком ШИМ. Поэтому рабочий диапазон стика преобразуется не в 0...255, а в 100...255:
static const uint8_t MIN_MOTOR_PWM = 100;
При положении стика в мёртвой зоне двигатель получает ноль. Как только команда выходит из неё, двигатель сразу получает ШИМ, достаточный для начала движения.
Это позволяет убрать ситуацию, когда мотор гудит, потребляет ток, но не может сдвинуть робот с места.
Особенность моторного драйвера
В предоставленной рабочей прошивке RobotX использовалась такая распиновка:
левый мотор: DIR D5, PWM D6; правый мотор: DIR D8, PWM D9.
Для движения вперёд используется:
digitalWrite(DIR_PIN, LOW); analogWrite(PWM_PIN, speed);
Но отрицательная скорость обрабатывается особым образом:
digitalWrite(DIR_PIN, HIGH); analogWrite(PWM_PIN, 255 + speed);
Здесь speed находится в диапазоне от -255 до -1.
То есть для этого драйвера нельзя просто установить направление и вызвать:
analogWrite(PWM_PIN, abs(speed));
Такая, на первый взгляд, стандартная замена изменит поведение уже проверенной схемы. Поэтому функции управления моторами были перенесены из рабочей прошивки без изменения полярности.
Управление сервоприводами
Помимо моторов Arduino управляет двумя сервоприводами:
левая серва → A4; правая серва → D7.
Для управления используются плечевые кнопки:
L1 — левая серва вверх; L2 — левая серва вниз; R1 — правая серва вверх; R2 — правая серва вниз.
Правая серва установлена зеркально, поэтому её внутренние направления программно поменяны местами.
Сервы не остаются постоянно подключёнными к генератору импульсов. Они активируются при нажатии кнопки и отключаются через 200 мс после окончания движения. Это уменьшает дрожание, нагрев и лишнее потребление тока.
Для генерации импульсов используется ServoTimer2. На ATmega328P эта библиотека занимает Timer2 и влияет на ШИМ D3 и D11. Моторы подключены к D6 и D9, поэтому конфликта таймеров в данной схеме нет.
Диагностика через тот же аппаратный UART
Хотя Nano получает пакеты через D0/RX, диагностические сообщения можно видеть в мониторе порта. Приём идёт по RX, а отладочный вывод — через D1/TX во встроенный USB–UART преобразователь Nano.
Монитор порта открывается на той же скорости — 57600 бод. Каждые 250 мс Arduino выводит одну строку:
SEQ=42 ONLINE=1 RX=127 RY=255 MOTOR_L=255 MOTOR_R=160 L1=0 L2=0 R1=1 R2=0 SERVO_L=90 SERVO_R=84
Вводить команды из монитора порта при подключённой ESP32-C3 не нужно: компьютер и ESP32-C3 в таком случае одновременно воздействовали бы на линию RX.
Два уровня аварийной защиты
ESP32-C3 отправляет изменившееся состояние с частотой до 20 Гц. Даже если оператор не двигает стики, каждые 200 мс передаётся контрольный кадр.
Это позволяет отличить неподвижный пульт от отключённого UART-провода.
Первый уровень защиты работает на ESP32-C3. Если корректные BLE-пакеты не поступают 350 мс, мост отправляет:
ONLINE=0
Оси и все кнопки при этом обнуляются.
Второй уровень находится уже на Arduino Nano. После каждой корректной строки сохраняется время её получения:
lastControllerLineMs = millis();
Если новый корректный пакет не появился за 500 мс, Nano самостоятельно очищает состояние и останавливает робот:
if (pad.online && millis() - lastControllerLineMs > CONTROLLER_TIMEOUT_MS) { pad = ControllerState{}; stopRobot(); }
Получаются два независимых предохранительных контура:
потеря BLE MBBTCTR01 ──────────→ ESP32-C3 передаёт ONLINE=0 обрыв UART ESP32-C3 ───────────→ Arduino Nano останавливает робот через 500 мс
Даже если ESP32-C3 зависнет, потеряет питание или от неё физически оторвётся провод, Nano не продолжит бесконечно выполнять последнюю принятую команду.
Именно это разделение делает мост полезным не только для одного робота. ESP32-C3 берёт на себя всю специфическую работу с MBBTCTR01, а основному контроллеру остаётся понятный, проверяемый и переносимый поток состояний по UART.
Репозиторий со схемами, фотографиями и GIF:
https://github.com/stepanburmistrov/makeBlock_pult
Что дальше
Сам по себе радиоуправляемый робот — уже отличный способ провести вечер. Но у этой истории может быть продолжение.
На роботе можно установить лидар, ехать с пульта на небольшой скорости и одновременно строить карту помещения. В таком режиме человек отвечает за безопасное перемещение, а бортовой компьютер собирает данные. После этого можно переходить к локализации и автономной навигации уже по готовой карте. Такой режим особенно удобен для аккуратного ручного объезда помещения перед переходом к автономной навигации.

Заключение
Пульт оказался не самым популярным, хотя я такие встречал на соревнованиях, но задача вокруг него — вполне общая.
Практически любое закрытое устройство можно исследовать по той же схеме:
переписать с корпуса точную модель, FCC ID и другие уникальные строки;
найти пользовательскую документацию и зафиксировать не только ответы, но и последовательность штатной работы устройства;
разделить факты, выводы и пока ещё непроверенные гипотезы;
определить роли участников соединения;
исследовать настоящий совместимый приёмник или найти его цифровые следы;
воспроизвести минимальный профиль и проверить только факт соединения;
получить сырые данные без преждевременных предположений;
менять по одному входному параметру;
искать старые продукты и открытый код производителя по характерным байтам и названиям функций;
подтвердить структуру сообщения несколькими независимыми способами;
написать потоковый парсер и проверить контрольную сумму;
отделить транспорт от логики устройства;
добавить fail-safe до подключения силовой части.
Пользуйтесь, люди добрые, на здоровье!
Ссылки
Репозиторий проекта: https://github.com/stepanburmistrov/makeBlock_pult
Инструкция Makeblock Bluetooth Controller: https://support.makeblock.com/hc/en-us/articles/24279831562519-Bluetooth-Controller
FCC-документы MBBTCTR01: https://fccid.io/2AH9Q-MBBTCTR01
Разбор GATT-профиля
Makeblock_LE: https://primalcortex.wordpress.com/tag/makeblock/Декодер MePS2 в Makeblock Libraries: https://github.com/Makeblock-official/Makeblock-Libraries/blob/master/src/MePS2.cpp
Bluetooth Low Energy Primer: https://www.bluetooth.com/bluetooth-le-primer/
BLE в Arduino Core for ESP32: https://docs.espressif.com/projects/arduino-esp32/en/latest/api/ble.html

