Недавно мне подарили робота Eilik от energize lab и я был весьма разочарован тем что от него не оказалось публичной sdk или хоть какой-нибудь возможности писать под него кастомные скрипты. Отсюда начинается мое приключение с реверс инжинирингом.
С чего всё началось
Робот определяется как USB CDC-ACM — обычный виртуальный COM-порт:
/dev/cu.usbmodem101
Первое, что делаешь в такой ситуации, снимаешь трафик фирменного приложения и смотришь на байты. Выглядели они так:
AA AA AA 14 00 61 ...AA AA AA 0A 00 FF ...AA AA AA 04 00 01 FA
Три байта AA в начале каждого пакета — очевидная сигнатура. Дальше начиналось интересное.
В сообществе к тому моменту уже ходили заметки, в которых 14 00 61 трактовались как трёхбайтовый заголовок типа канала, а следующие пять байт — как токен сессии, который якобы надо сначала получить от устройства и потом предъявлять в каждом пакете. Обе трактовки оказались неверными.
Конверт: длина, а не заголовок
Если разложить несколько пакетов разной длины в столбик, картина проясняется мгновенно:
|
пакет |
всего байт |
поле после |
|---|---|---|
|
heartbeat |
13 |
|
|
servo |
23 |
|
|
ACK |
13 |
|
Во всех случаях это полный размер пакета минус три. Не заголовок, а поле длины, uint16 little-endian. А байт за ним — идентификатор команды.
Итоговый конверт, одинаковый в обе стороны:
|
смещение |
размер |
поле |
|
|
0 |
3 |
AA AA AA |
сигнатура |
|
3 |
2 |
len (u16 LE) |
размер пакета минус 3 |
|
5 |
1 |
cmd |
код команды |
|
6 |
len-4 |
data |
аргументы |
|
len+2 |
1 |
cksum |
контрольная сумма |
Проверяется элементарно: пакеты фирменного приложения содержат в позиции команды 01, 20, 02 — и это ровно те команды, которые логично там ожидать:
AA AA AA 04 00 01 FA pingAA AA AA 04 00 20 DB read_all (SD)AA AA AA 09 00 02 00 09 00 00 00 EB confirm_upgrade
Откуда взялась ошибка с заголовком? Команда 0x61 это легаси-формат управления сервоприводами, и у неё первые пять байт данных действительно выглядят как непонятное поле. Человек посмотрел на 14 00 61, увидел три байта перед мусором и решил, что это заголовок. На одном формате пакета такая гипотеза неотличима от правильной. Она рассыпается, только когда сравниваешь пакеты разной длины, тогда 14 00 перестаёт быть константой и начинает коррелировать с размером.
Контрольная сумма
Считается по всем байтам от поля длины до конца данных, сам байт суммы не включается:
static unsigned char checksum(const unsigned char *data, int len) { unsigned int sum = 0; for (int i = 0; i < len; i++) sum += data[i]; return 255 - (sum % 256);}/* для пакета pkt длиной n: */pkt[n - 1] = checksum(pkt + 3, n - 4);
Обычное дополнение до единицы младшего байта суммы. Формула сошлась на всех перехваченных пакетах, включая те, что отправляло само устройство.
И важная деталь: прошивка её действительно проверяет. Пакет с испорченной суммой отбрасывается молча, без всякого ответа. Это удобнее, чем кажется: раз пришло подтверждение, значит кадр дошёл неповреждённым. Отдельный механизм контроля целостности не нужен.
Миф о «токене сессии»
Оставалось то самое пятибайтовое поле в команде 0x61, объявленное «токеном сессии». Логика заметок была такая: сначала получи токен, потом вставляй его в каждый пакет, иначе устройство тебя проигнорирует.
Проверить это стоило десяти минут:
-
Отправить пакет с полем, скопированным из дампа, работает.
-
Отправить с полем, забитым нулями, работает.
-
Отправить со случайным мусором в этом поле работает.
-
Посмотреть, меняется ли поле от пакета к пакету в дампе приложения, — меняется каждый раз.
Никакой валидации нет. Поле не проверяется вообще. Скорее всего это счётчик или временная метка, которую прошивка эхом возвращает и никак не использует.
Практический вывод: протокол не имеет состояния. Ни рукопожатия, ни сессии, ни токена, ни keepalive. Открыл порт — шли команды сколько хочешь.
Таблица команд, включая те, которые трогать нельзя
Собранная карта команд:
|
код |
что делает |
статус |
|---|---|---|
|
|
ping, чтение информации о MCU |
подтверждено |
|
|
чтение углов сервоприводов |
подтверждено, разобрано |
|
|
запись углов, до 4 моторов в одном пакете |
подтверждено |
|
|
чтение экрана, 1024 байта |
подтверждено |
|
|
запись экрана, 1024 байта |
подтверждено |
|
|
легаси-формат сервоприводов |
подтверждено |
|
|
heartbeat |
подтверждено |
|
|
read_all с SD, 2 КБ |
на моём устройстве не отвечает |
|
|
running number |
отвечает, но инертно |
|
|
confirm_upgrade, content_update |
не трогать!!! |
|
|
запись прошивки во flash |
не трогать!!! |
|
|
запись на SD |
не трогать!!! |
|
|
reinit и форматирование SD |
не трогать!!! |
Четыре команды в этой таблице способны превратить робота в кирпич. Я их не проверял и проверять пока не собираюсь: 0x04 пишет прошивку, 0x42 форматирует карту. Разбирать протокол — это в том числе понимать, где остановиться.
Если будете повторять — начинайте с 0x01. Он безобиден и сразу говорит, живой ли канал.
Скорость 125000 бод, которой не существует
В заметках фигурировала «нестандартная скорость 125000 бод». Звучит экзотично и требует на macOS отдельного системного вызова:
speed_t speed = 125000;ioctl(fd, IOSSIOSPEED, &speed);
Забавно, что для USB CDC-ACM это чистая формальность. Никакого UART на нужной стороне нет — данные идут по USB как есть, а «скорость порта» это просто число, которое передаётся устройству и которое оно вольно игнорировать. Реальная пропускная способность у меня измерялась примерно в 258 кадров в секунду, что к 125000 отношения не имеет.
Но выставлять её всё равно приходится: неизвестно, смотрит ли прошивка на это значение, а проверять методом «а что если» на единственном экземпляре робота не хочется 🙂
Сервоприводы: карта моторов
Команда 0xA2 принимает до четырёх моторов в одном пакете:
count, [id, pos_lo, pos_hi] × count
Позиции — uint16 little-endian. Осталось понять, какой номер какой части соответствует. Тут никакой магии, только эксперимент. Я написал тест из шести шагов и смотрел глазами на результат:
id 1 = правая рука
id 2 = левая рука
id 3 = корпус
id 4 = голова
Пределы хода
Определялись осторожным приближением к краям с прослушиванием — сервопривод, упёршийся в механический ограничитель, слышно:
|
сустав |
диапазон |
центр |
|---|---|---|
|
руки |
1150–1850 |
1500 |
|
корпус |
1350–1650 |
1500 |
|
голова |
1300–1700 |
1500 |
Значения для корпуса и головы намеренно консервативные. Библиотека по умолчанию не отправляет значения за пределами диапазона, а обрезает их.
Скорость
Меряется так: командуешь шаг через 0xA2 и опрашиваешь позицию через 0xA1 настолько быстро, насколько позволяет круговая задержка.
руки 617 единиц менее чем за 200 мс - не менее 3000 ед/скорпус/голова 290 единиц менее чем за 200 мс - не менее 1450 ед/с
Здесь важно что это нижняя граница, а не потолок. Сустав уже доехал к моменту первого полученного отсчёта, то есть измерение упёрлось в задержку чтения. Настоящий предел выше и остался неизвестным, чтобы его найти, нужен другой метод, например подавать синусоиду с растущей частотой и ловить момент, когда амплитуда обратной связи начнёт отставать от заданной.
Для практики хватило и нижней оценки: 3000 ед/с — это 100 единиц за кадр при 30 fps, и ограничитель в конвейере поставлен чуть ниже, на 90.
Экран: 128×64

Команды 0xA3 и 0xA4 читают и пишут ровно 1024 байта. Это выдаёт формат сразу: 128 × 64 пикселя при одном бите на пиксель.
Раскладка — классический SSD1306 в страничном режиме: восемь страниц по 128 столбцов, и внутри байта младший бит — верхняя строка своей страницы. То есть пиксель (x, y) это бит y % 8 байта (y / 8) * 128 + x.
Первая же выведенная картинка оказалась перевёрнутой. Не зеркальной — именно повёрнутой на 180°. Панель физически смонтирована вверх ногами относительно порядка байт, который ожидает контроллер. В библиотеке поворот делается при конвертации изображения, но не делается для готового кадрового буфера:
python
robot.screen.show("face.png") # картинка: масштабируется, бинаризуется, поворачиваетсяrobot.screen.show(framebuffer) # 1024 байта: уходят как есть
Запуск Bad Apple на Eilik
Когда стало ясно, что экран пишется целиком за один пакет, дальнейшее было предсказуемо и Bad Apple стал просто вопросом времени.
Конвейер простой: ffmpeg разбирает видео на кадры, каждый масштабируется в 128×64, бинаризуется по порогу и упаковывается в 1024 байта. 6572 кадра, 30 fps.
Заработало оно сразу без головной боли. После я решил добавить движения рук в такт. Учитывая что выше я уже разобрал работу сервоприводов, то оставалось только написать простой beats detection и заставить двигаться в соответствии с ним.
Тут я сознательно не стал тащить librosa и numpy. Инструмент работает на голой стандартной библиотеке: array, math, struct и ffmpeg снаружи. Для задачи разложить трек на доли этого хватает с головой.
Огибающая. ffmpeg разворачивает звук в моно 22050 Гц, дальше считается RMS ровно на окне в один видеокадр и нормируется в 0..1. На выходе по одному числу громкости на каждый кадр видео, то есть звук и картинка с самого начала живут в одной сетке времени, и синхронизировать потом ничего не нужно.
Атаки. Доли ищутся не по самой громкости, а по её приросту: берётся положительная разность соседних значений огибающей и сглаживается по трём кадрам. Дальше адаптивный порог — пик считается атакой, если он в 1.6 раза выше локального среднего за ±0.5 секунды, превышает абсолютный минимум и отстоит от предыдущего не меньше чем на 0.18 секунды.
Сетка. Атаки — это ещё не доли: они срабатывают на любом резком звуке, и попадающем в такт, и мимо. Поэтому дальше идёт перебор гребёнкой: период от 200 до 50 ударов в минуту с шагом в полкадра, и для каждого двенадцать вариантов фазы. Побеждает тот период, чьи узлы попадают в атаки заметно чаще, чем попадала бы случайность.
Октава. У любого детектора темпа есть неустранимая двусмысленность: 60 и 120 ударов в минуту описывают одну и ту же музыку, просто на разных уровнях. Формально верны оба ответа, но робот, машущий рукой раз в секунду, выглядит вяло. Поэтому к оценке добавлен мягкий вес вокруг 120 темпа. На выходе сырой трек .mv: по одному кадру на кадр видео, в каждом четыре uint16 little-endian это целевые позиции моторов 1..4. Он ложится ровно 1:1 на поток кадров экрана, так что во время воспроизведения не надо ничего считать: читай и отправляй.
Плюс два ограничителя, оба продиктованы железом. Первый: между соседними кадрами ни один сустав не просят сдвинуться больше чем на 90 единиц. При 30 кадрах в секунду это чуть ниже измеренной скорости. Второй: движения уходят не каждый кадр, а каждый третий. Сервоприводы просто физически не отрабатывают тридцать команд в секунду.
Динамик — честный тупик
Раз есть экран, логично захотеть звук. У робота есть динамик он пищит и попискивает в фирменном приложении.
Я потратил на это заметное время и не нашёл ничего.
-
В протоколе нет команды передачи аудио.
-
В дампах фирменного приложения нет пакетов, похожих на звуковые данные.
-
Пищит робот, судя по всему, встроенными в прошивку сэмплами, которые триггерятся эмоциями.
-
Единственный теоретический путь, это модификация прошивки, то есть та самая команда
0x04, которая пишет во flash. С риском получить кирпич.
Поэтому в библиотеке звук выводится на колонки компьютера, синхронно с картинкой на роботе. И в документации прямо написано, что через динамик робота это невозможно.
Танцы: две хореографии под бит
Дальше — движения под музыку. Получилось два режима.
simple ничего не требует от музыки, кроме громкости. Каждая рука качается своим осциллятором с периодом, который не делится на период другой, поэтому они никогда не сваливаются в зеркальное повторение друг друга. Работает под что угодно, включая речь и шум.
guitar даёт рукам разные роли, как у двух рук гитариста: одна отбивает долю, вторая переставляется на границах тактов и держит между ними. Требует музыки с внятным пульсом и без него выглядит бессмысленно.
Библиотека: C-ядро и Python поверх
Когда всё заработало в виде набора скриптов, захотелось нормальной библиотеки.
Архитектура: ядро на C со стабильным ABI, поверх тонкая обёртка на Python через ctypes. Не расширение CPython, а именно обычная разделяемая библиотека.
Публичный API получился маленьким:
python
import eilikwith eilik.connect() as robot: robot.arm_left.to(1800) robot.head.to(1600) robot.commit() # оба сустава двигаются одним пакетом print(robot.positions()) robot.rest()
Разделение «поставить в очередь» и «отправить» — не украшательство. Один пакет несёт до четырёх моторов, и это единственный способ сдвинуть их синхронно. Отправка по пакету на сустав даёт заметно рваное движение: суставы приходят в целевые позиции с разбежкой в десятки миллисекунд, и глаз это ловит.
Что получилось?
pip install pyeilik
import eilikwith eilik.connect() as robot: robot.play("clip.mp4", dance="simple")
-
Ядро на C со стабильным ABI, обёртка на Python.
-
Четыре сустава с проверкой пределов, синхронное движение одним пакетом.
-
Экран в обе стороны: показать картинку, прочитать текущий кадр.
-
Видео 30 fps со звуком на колонки и движениями в такт.
-
Внятные ошибки, называющие лекарство, а не симптом.
-
Документация протокола на 892 строки — с указанием, что проверено на железе, а что нет.
Так же помимо всего я собрал простой сайт под мою библиотеку, с полной документацией а так же описанием внутренностей протокола: eiliksdk.com
Следующим же шагом является запуск Doom на нем 🙂
Ссылки
-
Документация и полный разбор протокола: eiliksdk.com
-
Библиотека: pypi.org/project/pyeilik
-
Исходники: github.com/aklto/PyEilik (если вам понравился проект, поставьте звездочку на репу)
Проект не связан с производителем робота и не поддерживается им. Название использовано только для того, чтобы сказать, с каким устройством библиотека разговаривает.
ссылка на оригинал статьи https://habr.com/ru/articles/1075016/