В первой статье я рассказывал, как мы в лаборатории 361.Robotics построили русскоязычный голосовой стек для китайского гуманоида Walker Tienkung TK2301 — своё распознавание речи, синтез и логику диалога вместо вендорских, а во второй — как настраивали внешний USB‑микрофон, через который этот стек слушает оператора. Но прежде чем робот вообще может пойти или совершать любые другие физические действия по команде голосом, ему нужно пройти путь от «висит выключенный» до «стоит и готов выполнять команды» — вот именно этот путь долгое время был недоступен программно, только через физический пульт в руках оператора. Наш рассказ будет о том, как мы обошли это ограничение.

Начнём с того, что у робота есть штатный пульт с рычажками и кнопками — вендор предлагает оператору через него управлять действиями робота: поднять его на ноги, перевести в режим ходьбы и обратно. Один из рычажков пульта мы уже задействовали раньше: им включался и выключался голосовой режим работы оператора с роботом. Но цепочка подготовки к физическим действиям — «разбудить моторы», «перейти в нулевую позу», а главное «встать из этой позы в положение стоя» — долгое время оставалась возможной только с пульта. Причём последний шаг, переход из нулевой позы в позу стоя, как оказалось программно заблокирован производителем намеренно, о чём подробнее ниже.

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

Почему нельзя просто послать тот же сигнал, что шлёт кнопка?

Первая идея была самой очевидной: у пульта есть кнопки, кнопки посылают роботу какие‑то сигналы через внутреннюю шину, значит можно попробовать опубликовать точно такой же сигнал программно — и робот не отличит нашу команду от настоящего нажатия. Идея не сработала при первой же проверке.

Весь путь от «висит выключенный» до «стоит» долгое время существовал только в виде этих рычажков и кнопок.
Весь путь от «висит выключенный» до «стоит» долгое время существовал только в виде этих рычажков и кнопок.

Мы честно попробовали опубликовать сообщение, имитирующее короткое нажатие кнопки самодиагностики (на штатном пульте это кнопка A) и кнопки перехода в нулевую позу (кнопка D) в те каналы, которые, по всем признакам, должны были их принимать. Ничего не произошло — ни разу, хотя параллельно было видно, что на настоящие физические нажатия тех же кнопок робот реагирует.

«Загадка...» подумал я: логика, которая слушает пульт, вроде бы подписана именно на этот канал, что нужно, реальные нажатия кнопок там видны, а наши искусственные сообщения имитирующие нажатия кнопок будто проваливаются в пустоту. Появилось несколько версий: может, канал фильтрует сообщения по какому‑то внутреннему идентификатору отправителя, может, само нажатие идёт вообще по другому, скрытому от нас каналу, а то, что мы видим — только часть общей картины. На этом этапе стало ясно, что просто «отправить такое же сообщение» недостаточно — нужно разобраться, что происходит внутри гораздо глубже.

Если сигнал нельзя имитировать — можно ли его записать и проиграть?

Следующим шагом мы записали настоящее нажатие кнопки как есть — весь поток данных с пульта во время одного клика — и просто воспроизвели эту запись обратно в систему, как проигрывают аудиозапись. И это сработало. Робот отреагировал на воспроизведённую запись точно так же, как на живое нажатие: прошла самодиагностика, моторы включились.

Это был важный шаг вперёд в понимании: дело было не в том, что канал в принципе недоступен программно, а в том, что наша первая попытка воссоздать сигнал вручную была недостаточно точной — что‑то в форме сигнала (скорее всего, точный тайминг между «нажал» и «отпустил») не совпадало с тем, что ожидает система. Запись содержала этот тайминг в точности как сигнал с пульта, потому что это было настоящее нажатие.

Но у подхода «воспроизвести запись» есть очевидный недостаток: это негибко. Запись — это фиксированный кусок, который можно только проиграть от начала до конца в реальном времени. Для демонстрации «смотрите, программно можно» — отлично. Для рабочего инструмента — не годится.

Почему свой код сначала тонул в фоновом потоке?

Раз воспроизведение записи работает, значит дело не в непонятном препятствии, а в точности сигнала — можно написать свой код, который генерирует тот же самый сигнал по требованию. Написали. Разобрали записанный сигнал по кусочкам, поняли, из чего он состоит (по сути — короткая пара событий «нажал» и «отпустил» с определённым точным интервалом между ними), и написали модуль, который эту пару событий генерирует сам, в любой момент, когда попросят.

Первая версия этого кода не заработала. Дело оказалось в частоте, с которой мы посылали сигнал. Пульт всё это время продолжает посылать свой собственный фоновый поток данных примерно 43 раза в секунду, даже когда ничего не нажато — это своего рода постоянный «пульс» системы. Наш искусственный сигнал должен был на фоне этого пульса выделиться как чёткий скачок, а не потеряться в нём. Если посылать слишком редко, наш сигнал просто «тонет» — система видит его вперемешку с фоновыми сигналами пульта и не может понять, что это действительно нажатие, а не шум.

Пришлось слать на частоте 200 Гц — почти в пять раз чаще фонового потока, чтобы наш сигнал гарантированно перебивал его, а не проигрывал ему в конкуренции за внимание. После этого всё заработало: голосовая команда «приготовься» стала программно запускать самодиагностику и переводить робота в нулевую позу — без единого касания пульта.

Диаграмма 2. Три идеи для программной имитации короткого нажатия кнопки — от прямой копии сигнала до рабочего решения с подобранной частотой.
Диаграмма 2. Три идеи для программной имитации короткого нажатия кнопки — от прямой копии сигнала до рабочего решения с подобранной частотой.

Почему робот отвечал «принято» — и не вставал?

Дальше началось самое интересное. Разбудить моторы и перейти в нулевую позу голосом мы научили робота относительно быстро. А вот следующий шаг — встать из этой нулевой позы в положение стоя — не поддавался дольше, и причина оказалась совсем другой. Это был не вопрос точности или частоты сигнала, а осознанное ограничение производителя: перевод в положение стоя из нулевой позы штатными программными командами не срабатывал, хотя формально команда принималась системой без ошибки. То есть система говорила «принято», но робот оставался висеть в нулевой позе.

Копать пришлось намного глубже, вплоть до разбора внутреннего устройства управляющего программного модуля робота. Выяснилось следующее: переход из нулевой позы в стоячую внутри устроен как реакция именно на длинное удержание той же кнопки A на пульте (не короткое нажатие, а именно удержание в течение примерно секунды) — и этот путь физически работает совершенно независимо от программных команд, которые формально должны делать то же самое.

Мы зашли в тупик: сигнал от пульта, судя по всему, был единственным работающим триггером, а мы уже умели этот сигнал воспроизводить программно для короткого нажатия — значит, оставалось разобраться, почему то же самое не получается для длинного нажатия с удержанием.

Нулевая поза: моторы включены, робот проинициализирован и готов — но команда «встать» отсюда молча игнорировалась, хотя система отвечала «принято».
Нулевая поза: моторы включены, робот проинициализирован и готов — но команда «встать» отсюда молча игнорировалась, хотя система отвечала «принято».

Как прочитать логику, которой нет в исходниках?

Тут возникла новая проблема: та часть управляющего софта, что отвечает за реакцию на пульт, — это закрытый вендорский модуль, скомпилированная библиотека без исходного кода в открытом доступе, и просто «почитать, как это работает» было неоткуда. Единственный оставшийся способ понять логику изнутри — дизассемблировать сам бинарник, то есть превратить машинный код обратно в читаемые, пусть и низкоуровневые, инструкции процессора и построчно посмотреть, что программа делает на самом деле (это дизассемблирование, а не декомпиляция — восстановить настоящий код на C++ из этого не получится, но для наших целей хватало и голых инструкций). Инструмент для этого стандартный и давно всем известный — objdump -d.

Была ещё один нюанс: дизассемблировать нужно было именно на самом роботе по SSH, а не перетаскивать файл к себе на рабочий компьютер — бинарник собран под процессор робота (x86-64), а рабочая машина оператора на другой архитектуре (Apple Silicon), и дизассемблер должен понимать именно тот «язык» процессора, под который собран конкретный файл, иначе это как пытаться читать книгу по словарю чужого языка.

Достаточно много времени ушло на то, чтобы буквально построчно пролистать дизассемблированный код в поисках места, которое отвечает за удержание кнопки — и наконец‑то оно нашлось. Первое, что стало понятно: там действительно есть счётчик, который отсчитывает, сколько времени кнопка удерживается нажатой, и порог этого счётчика зашит в коде жёстко в виде ровно одной секунды — не «примерно секунда», как мы предполагали по ощущениям, а буквально инструкция сравнения с константой 0×3e8, то есть 1000 миллисекунд. Это было приятное подтверждение своей же догадки фактом, но самое важное открытие ждало рядом: в том же месте кода было видно, что счётчик удержания обнуляется каждым сообщением из фонового потока пульта — буквально каждым, без исключений.

Та самая строчка: сравнение счётчика удержания с 0x3e8 — ровно 1000 миллисекунд.
Та самая строчка: сравнение счётчика удержания с 0×3e8 — ровно 1000 миллисекунд.

И вот тут всё встало на свои места: понятно, почему увеличение частоты собственных сообщений никогда не помогало и не могло помочь — физически невозможно накопить секунду непрерывного удержания, если фоновый поток постоянно врывается в тот же канал и раз за разом обнуляет счётчик. До этого момента у нас была рабочая гипотеза, а после дизассемблирования — неоспоримый факт.

Для очистки совести гипотезу проверили ещё раз с другой стороны: на GitHub нашёлся открытый исходный код RL‑контроллера (control stack на основе обучения с подкреплением) для того же самого робота той же линейки Tien Kung — Open‑X-Humanoid/Deploy_TienkungOpen‑X-Humanoid, он же Пекинский инновационный центр гуманоидной робототехники, — это не случайное стороннее сообщество, а отраслевой консорциум, который в 2023 году был создан в том числе самим производителем нашего робота (вместе с несколькими другими компаниями) как раз вокруг платформы Tien Kung. То есть это открытый код, к появлению которого причастен тот же вендор, что поставляет закрытую версию на нашем роботе — только эта версия, судя по всему, живёт по своим, более открытым правилам. В этом открытом коде переход из нулевой позы в положение стоя запускается одним‑единственным событием пульта — без какого‑либо механизма долгого удержания вообще. Это стало последним недостающим доказательством: ограничение с долгим удержанием — не техническая необходимость самой механики баланса, а препятствие, которое где‑то по дороге к нашему конкретному роботу добавили поверх базовой логики.

Как удержать кнопку секунду, если канал не твой?

Теперь, когда причина из гипотезы превратилась в доказанный факт, оставалось решить чисто инженерную задачу: как накопить секунду непрерывного удержания, если фоновый «пульс» пульта постоянно лезет в тот же канал и обнуляет счётчик на каждом своём сообщении. С коротким нажатием было проще — это всего одна пара событий «нажал‑отпустил», и наш более частый сигнал легко перебивал редкий фон на этот короткий момент. А длинное удержание требует, чтобы сигнал «кнопка нажата» шёл непрерывно, без единого перерыва, в течение целой секунды — и тут уже не помогала никакая частота: как ни увеличивай темп нашей посылки, фон всё равно проскакивал между кадрами и обнулял накопленное время удержания, ведь для этого достаточно даже одного‑единственного фонового сообщения. Контрольная запись канала показала масштаб бедствия: при нашей посылке на 200 Гц самая длинная непрерывная серия «кнопка нажата» составила около 22 миллисекунд — в 45 раз короче требуемой секунды.

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

Единственным выходом было решение устранить фоновый поток совсем на время удержания: на секунду‑две останавливать штатный процесс, который этот фон генерирует, посылать наш непрерывный сигнал долгого нажатия без единой помехи, а затем возобновлять штатный процесс как ни в чём не бывало. Это сработало: непрерывная серия наконец пробила нужный порог длительности, и робот выполнил переход из нулевой позы в положение стоя — программно, без прикосновения к пульту.

Диаграмма 3. Заморозка фонового потока пульта на время удержания сигнала — единственный способ пробить порог долгого нажатия
Диаграмма 3. Заморозка фонового потока пульта на время удержания сигнала — единственный способ пробить порог долгого нажатия.

Почему нельзя собрать все три шага в одну голосовую команду?

Пока фоновый поток пульта приостановлен, физический аварийный стоп с пульта, которым оператор может мгновенно остановить робота, на эти секунды перестаёт работать. Это значит, что использовать этот обход вендорского ограничения можно только при одном обязательном условии: рядом должен быть отдельный, независимый от пульта аппаратный аварийный стоп, физическая кнопка, которая обесточивает робота напрямую, а не через пульт.

Из‑за этого мы приняли жёсткое правило: переход из нулевой позы в положение стоя никогда не выполняется автоматически в цепочке с другими командами и всегда запускается отдельно. Оператору каждый раз перед этим шагом нужно убедиться: робот на подвесе, его положение позволяет физически встать, аппаратный аварийный стоп под рукой.

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

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

Так научился ли робот вставать по голосовой команде?

Финальный голосовой сценарий сейчас выглядит так: команда «приготовься» будит моторы, проводит самодиагностику и переводит робота в нулевую позу, затем всегда отдельная команда поднимает робота в положение стоя, и уже из положения стоя доступны голосовые команды «иди», «беги», «стой» и жесты. По пути к этому результату пришлось:

  • убедиться, что прямая имитация нажатия кнопки не работает, и разобраться, что дело в точности сигнала, а не в принципиальной недоступности канала;

  • пройти через промежуточный, но негибкий обходной путь — воспроизведение записанного нажатия как есть;

  • написать свой код, генерирующий тот же сигнал по требованию, и подобрать частоту его посылки, чтобы он не терялся в фоновом потоке пульта;

  • обнаружить, что переход в положение стоя — это отдельный, специально заблокированный производителем механизм, который реагирует только на длинное удержание кнопки на пульте;

  • дизассемблировать закрытый вендорский бинарник прямо на роботе и увидеть в самом коде и точный порог удержания в одну секунду, и причину, по которой фоновый поток пульта каждый раз обнуляет счётчик, а в открытом коде Open‑X-Humanoid/Deploy_Tienkung для того же робота найти тот же переход без долгого удержания вообще — то есть подтвердить, что это осознанный добавленный барьер, а не техническая неизбежность;

  • понять, что для длинного удержания одного повышения частоты недостаточно — фоновый поток пульта нужно на время полностью остановить, а не просто «перекричать»;

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

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


Это третья история из работы с этим роботом: первая была про сам голосовой стек, вторая — про здоровье внешнего микрофона. Впереди ещё несколько — если тема интересна, подписывайтесь, расскажем.

Буду рад вопросам в комментариях, и отдельно интересно мнение тех, кто обходил ограничения закрытого вендорского софта на своём железе: где вы проводите границу между «автоматизировать до конца» и «оставить человеку»? Мы для единственного шага с активной балансировкой выбрали явное подтверждение оператора — но не уверен, что все бы решили так же.

Сейчас у нас в лаборатории работают четыре платформы: гуманоиды Unitree G1 EDU+Leju Kuavo 4Pro и UBTech Walker Tienkung — Embodied Intelligence, а также промышленный квадрупед Unitree B2. Мы активно экспериментируем с ними и обучаем роботов сценариям реального применения и решению бизнес‑задач. О других кейсах расскажем в будущих статьях на Хабре. А пока мы их пишем, читайте наш Telegram‑канал 361.Robotics о робототехнике, воплощенном ИИ и новой экономике вокруг них.

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

Мы открыты к обмену опытом, сотрудничеству, совместной работе над интересными проектами и просто знакомству с увлеченными людьми. Хотите увидеть современных роботов воочию — напишите нам и приезжайте на экскурсию в лабораторию 361.Robotics в Москву (м. Павелецкая).