Как я вскрыл протокол робота Eilik и научил его танцевать под Bad Apple

от автора

Недавно мне подарили робота Eilik от energize lab и я был весьма разочарован тем что от него не оказалось публичной sdk или хоть какой-нибудь возможности писать под него кастомные скрипты. Отсюда начинается мое приключение с реверс инжинирингом.

Само видео с Bad Apple

С чего всё началось

Робот определяется как 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 трактовались как трёхбайтовый заголовок типа канала, а следующие пять байт — как токен сессии, который якобы надо сначала получить от устройства и потом предъявлять в каждом пакете. Обе трактовки оказались неверными.

Конверт: длина, а не заголовок

Если разложить несколько пакетов разной длины в столбик, картина проясняется мгновенно:

пакет

всего байт

поле после AA AA AA

heartbeat

13

0A 00 = 10

servo

23

14 00 = 20

ACK

13

0A 00 = 10

Во всех случаях это полный размер пакета минус три. Не заголовок, а поле длины, 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

контрольная сумма

Проверяется элементарно: пакеты фирменного приложения содержат в позиции команды 012002 — и это ровно те команды, которые логично там ожидать:

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, объявленное «токеном сессии». Логика заметок была такая: сначала получи токен, потом вставляй его в каждый пакет, иначе устройство тебя проигнорирует.

Проверить это стоило десяти минут:

  1. Отправить пакет с полем, скопированным из дампа, работает.

  2. Отправить с полем, забитым нулями, работает.

  3. Отправить со случайным мусором в этом поле работает.

  4. Посмотреть, меняется ли поле от пакета к пакету в дампе приложения, — меняется каждый раз.

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

Практический вывод: протокол не имеет состояния. Ни рукопожатия, ни сессии, ни токена, ни keepalive. Открыл порт — шли команды сколько хочешь.

Таблица команд, включая те, которые трогать нельзя

Собранная карта команд:

код

что делает

статус

0x01

ping, чтение информации о MCU

подтверждено

0xA1

чтение углов сервоприводов

подтверждено, разобрано

0xA2

запись углов, до 4 моторов в одном пакете

подтверждено

0xA3

чтение экрана, 1024 байта

подтверждено

0xA4

запись экрана, 1024 байта

подтверждено

0x61

легаси-формат сервоприводов

подтверждено

0xFF

heartbeat

подтверждено

0x20

read_all с SD, 2 КБ

на моём устройстве не отвечает

0xA5/0xA6

running number

отвечает, но инертно

0x02 0x03

confirm_upgrade, content_update

не трогать!!!

0x04 0x05

запись прошивки во flash

не трогать!!!

0x31

запись на SD

не трогать!!!

0x41 0x42

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. Инструмент работает на голой стандартной библиотеке: arraymathstruct и 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 на нем 🙂

Ссылки

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

ссылка на оригинал статьи https://habr.com/ru/articles/1075016/