Unitree Go2: получаем root, исправляем Wi-Fi и добавляем русские голосовые команды

В нашей Unitree Go2 был штатный голосовой помощник BenBen. По-русски он с нами не разговаривал, а собака не понимала русские команды. Хотелось сказать «сидеть», «лежать» или «дай лапу» прямо роботу, без телефона в роли переводчика.
Начали разбираться с голосом. По дороге получили root основного компьютера, исправили срывы подключения к Wi-Fi и обнаружили программные команды, для которых не нашли кнопок в используемом приложении: от обычных поз до сальто. Затем собрали собственный голосовой мост и научили Go2 подходить и следовать за человеком с UWB-пультом. Всё это работает на борту.
Я Лев Ларин, инженер Unibot. В прошлой серии мы подключали робота-доставщика SpeedyBot к своей платформе. Теперь разбираемся с Unitree Go2. В этой статье — доступ к системе и русское управление; развёртывание локальной LLM, шумоподавление и задержки разговора заслуживают отдельного разбора.
Постановочная ИИ-иллюстрация по фотографии автора. Домашнее голосовое управление — тот самый сценарий, ради которого всё началось.
Чего мы хотели от BenBen
Задача выглядела скромно: собака должна понимать русские команды и отвечать по-русски через собственный динамик. Для работы ей не должны требоваться запущенный ноутбук и открытое приложение на телефоне. Дальше хотелось добавить свободный разговор и возможность менять голос, характер и знания под мероприятие.
У штатного голосового управления несколько частей. Есть офлайн-голосовой модуль, есть BenBen в приложении, есть распознавание, генерация ответа и синтез. Русский текст в одном из этих звеньев ещё не даёт работающий русский диалог.
В сохранённом коде штатного помощника текст мог пройти дальше без потери кириллицы, но обычная ветка синтеза выбирала китайский или английский TTS. Передача готового PCM позволяла этот синтез обойти. Одновременно выяснилось, что часть офлайн-речи связана с отдельным голосовым модулем и его ресурсами: заменить несколько WAV в Linux и получить полную русификацию не получалось.
Поэтому мы занялись собственной голосовой цепочкой. Это решало нашу практическую задачу, не требуя сначала перепрошивать голосовой микроконтроллер. Его мы в итоге и не прошивали.
Два компьютера, доступ к одному
Наш экземпляр — Go2 EDU с прошивкой 1.1.15. Внутри работают основной ARM-контроллер Unitree и NVIDIA Jetson Orin Nano с примерно 8 ГБ памяти. Эти сведения относятся к нашей комплектации.
На основном контроллере находятся штатные сервисы управления, USB-микрофон и динамик. Jetson подходит для распознавания речи и языковой модели, но того же USB-аудиоустройства на нём нет. Компьютеры связаны внутренним Ethernet:

Микрофон находится на компьютере, к которому сначала не было SSH-доступа. Установить распознаватель на Jetson оказалось недостаточно.
Доступ к Jetson у нас уже был. На основном контроллере SSH не слушал. Подключённый кабель этого не менял: с самой Jetson получали отказ соединения на SSH-портах, хотя WebRTC-сервис основного компьютера отвечал.
Это был первый существенный поворот. Чтобы разобраться со штатным голосом и поставить сервис рядом с микрофоном, понадобился административный доступ к другому компьютеру той же собаки.
Root через программируемую кнопку
Путь нашёлся в сервисе programming_actuator, который исполняет загруженные программы по комбинации кнопок пульта. Мы изучили публичный UnLeash-Lite и использовали описанный там механизм для своей процедуры получения доступа.
Суть в порядке инициализации Python. Программа работает в окружении с ограничениями, но sitecustomize.py исполняется при запуске интерпретатора до загрузки seccomp-фильтра. Возможность записать этот файл позволяет вынести подготовленный код за ограничения песочницы. Связь с сервисом шла через WebRTC-мост с аутентификацией ключом нашего устройства.
Готовый сценарий разблокировки менял больше настроек, чем требовалось для нашей задачи. Мы подготовили временный вариант: сохранить исходные Python-файлы с метаданными, установить однократный хук, а при следующем запуске интерпретатора сначала восстановить оригиналы и только затем поднять отдельный SSH по ключу.
Программу назначили на свободную комбинацию L1+Y. Потребовались два физических нажатия с паузой: первое записывало хук, второе запускало его в новом интерпретаторе. После этого открыли root-сессию на временном порту 2222.

Схема нашей процедуры. После срабатывания временного хука исходные Python-файлы восстанавливаются.
Сверили восстановленные файлы: содержимое, владельцев, права и время изменения. Удалили нашу привязку кнопки и временные файлы. Штатные пароли, основной SSH-конфиг и скрипты обновления при этой процедуре не меняли.
Получив доступ, сделали резервные копии обоих компьютеров с проверкой SHA-256 и чтением архивов. Это копии работающих файловых систем с дополнительными образами разделов, а не атомарный снимок всей собаки.
Для дальнейшей работы завели отдельного пользователя go2owner с sudo и отдельную SSH-службу на порту 2223. Этот постоянный доступ проверили после полного выключения и включения робота. Временный root-вход выполнил свою задачу; повседневная работа продолжилась через собственную учётную запись.
Wi-Fi помнил старую неудачу
Пока разбирались с доступом, приходилось заниматься и связью. Go2 могла подключиться к домашней сети при одном запуске и остаться без Wi-Fi при следующем. Сеть и пароль были сохранены, но это не гарантировало повторного подключения.
В неудачном запуске штатный скрипт сообщал network not found, затем завершал wpa_supplicant. IP-адрес на wlan0 так и не появлялся. В удачном запуске начальные ошибки сканирования тоже встречались, однако через несколько секунд робот подключался и получал адрес по DHCP.
Разница нашлась в скрипте:
/unitree/module/network_manager/upper_bluetooth/wpa_connect/wpa_connect.sh
Он искал ошибки по всему журналу, включая сообщения прежних попыток. Проверка ошибок стояла раньше проверки текущего wpa_state=COMPLETED. Кроме того, для отсутствующей сети был ранний выход примерно через десять секунд при общем тайм-ауте тридцать секунд. При неуспехе cleanup останавливал supplicant, поэтому продолжить сканирование было уже некому.
Исправили порядок: сначала проверять текущее соединение, затем учитывать ошибки только текущей попытки. Промежуточный неудачный scan перестал быть поводом оборвать весь отведённый срок подключения.

Исправление касается алгоритма подключения. Радиопричина первого неудачного сканирования осталась отдельным вопросом.
Упрощённо исправленный порядок выглядит так:
while connection_deadline_not_reached():
if current_wpa_state() == "COMPLETED":
return CONNECTED
error = read_errors_from_current_attempt()
if error in (WRONG_PASSWORD, AUTHENTICATION_FAILED):
return AUTH_ERROR
# Неудачный scan оставляет время на следующую попытку.
wait_for_next_check()
Это псевдокод порядка проверок. Рабочая правка выполнена в штатном shell-скрипте; параметры сети она не меняет.
Добавили отдельную службу восстановления. Раз в тридцать секунд она проверяет состояние; даёт работающему supplicant время восстановиться самостоятельно и при необходимости повторяет подключение штатным скриптом. В режиме точки доступа и во время переключений, запущенных приложением, служба не вмешивается. SSID и пароль берутся из прежней конфигурации.
Проверили восстановление после намеренной остановки supplicant: служба повторила подключение, получили COMPLETED и IP-адрес. Для возвращения приложения в этой проверке потребовался его перезапуск, после чего соединение восстановилось.
Причину первого неудачного радиосканирования мы этим не установили. Зато исправили конкретную программную причину, по которой временная неудача оставляла собаку без сети.
Через Wi-Fi открыли наш SSH основного контроллера, а Jetson стала доступна через ProxyJump. Получилась обычная схема обслуживания: ноутбук → домашняя сеть → основной контроллер → внутренний Ethernet → Jetson. Кабель для каждой правки больше не нужен.
Сальто есть, кнопки нет
Следующим вопросом был список действий. Мы хотели связать русские слова с реальными командами, а для этого требовалось понять, что собака вообще умеет принимать.
В используемом нами приложении и на пульте не было полного набора нужных команд. Искали «сидеть», «лежать», трюки; в программных интерфейсах обнаружился значительно более широкий каталог. В том числе сальто вперёд, назад и влево, режимы передних и задних лап, несколько походок.
Ориентировались на SDK, установленный именно в нашей прошивке, и сверяли его с публичным SDK Unitree. У каждого вызова есть конкретный API ID и формат параметров. Например:
Русская команда | Метод Sport API | API ID |
|---|---|---|
Встать |
| 1004 |
Лежать |
| 1005 |
Сидеть |
| 1009 |
Подняться из сидячего положения |
| 1010 |
Дай лапу |
| 1016 |
Сальто вперёд |
| 1030 |
Боковое сальто |
| 2041 |
Сальто назад |
| 2043 |
StandDown — штатное опускание корпуса; это значение мы связали с русским «лежать». HandStand и WalkUpright включают соответствующие режимы, а не задают маршрут или число шагов.
Мы не создавали алгоритм сальто. Нашли существующую точку входа и добавили к ней понятную русскую команду. При этом наличие метода в SDK не доказывает, что конкретная прошивка выполнит его в любом текущем режиме.
В итоге каталог вырос до 93 голосовых намерений и 39 операций публичного Sport API. Намерения включают также свет, звук, разговорные настройки и навигацию, поэтому это не «93 разных трюка». Несколько формулировок могут вести в один обработчик: «сидеть» и «сядь» означают одно действие.
Полное покрытие каталога мы проверяли на уровне разбора фраз и формирования вызовов. Все сальто и стойки физически не испытывали.

Настоящий скриншот каталога: три намерения для сальто. Наличие карточки и обработчика не означает физическую приёмку трюка.
Принять команду и выполнить трюк — разные события
Для некоторых операций недостаточно отправить API ID. Нужны параметры с правильными типами. Например, Move получает линейные скорости и угловую скорость, а переключение режима — булево поле data.
Поэтому сделали типизированный адаптер: известный метод, ожидаемые поля, проверка типов и конечности чисел, затем JSON для штатного API. Неизвестные методы и дополнительные параметры он не отправляет.
Отдельно разобрали ответы. В нашем адаптере различаются принятие контроллером, отказ с кодом 3203 и отсутствие подтверждения с кодом 3104. Последний случай не приводит к автоматической повторной отправке движения: неизвестно, потерялся запрос или только ответ.
Так выглядит сокращённая логика обработки результата из адаптера:
if code == 3104:
status = "sent_unconfirmed" # Автоматически не повторяем.
elif code == 3203:
status = "unsupported" # Не реализовано в текущем режиме.
elif code:
status = "rejected"
else:
status = "accepted" # Принято контроллером.
Даже успешное подтверждение означает «контроллер принял команду». Для утверждения «собака выполнила сальто» нужна проверка самого движения. Этот статус сохранили и в программном интерфейсе, и в формулировках ответов.
Та же точность понадобилась для возможностей, которым не хватает оборудования или настройки. У нас нет автоматической зарядной станции, поэтому «иди на зарядку» не должно завершаться обещанием отправиться к несуществующему доку. Для патруля нужны настроенные карта и маршрут. Каталог может описывать такие действия, но обработчик должен объяснять, чего сейчас не хватает.
Подключаем голос к штатным API
Список команд был готов, но оставался вопрос, откуда брать звук. USB-микрофон основного контроллера уже использовал штатный audiohub. На Jetson его не было, а останавливать audiohub ради собственного arecord мы не хотели.
В работающей системе нашли DDS-тему rt/audiosender с сообщениями AudioData_. В поле data приходил сжатый Opus. Название типа не означает, что эти байты можно сразу передать распознавателю как PCM.
Подписались на штатный поток, декодировали пакеты через libopus в mono PCM16 с частотой 16 кГц и передали звук на Jetson по внутреннему Ethernet. Обратным путём идут действия и озвученные ответы.
Микрофон → штатный audiohub → DDS / Opus
↓
наш сервис основного контроллера
↓ PCM по Ethernet
Jetson: обработка и распознавание
↓
диспетчер русских команд
↓ действие по Ethernet
основной контроллер → штатный API
Между нашими сервисами — TCP-соединение с кадрированием: длина JSON-заголовка, сам заголовок и двоичная полезная нагрузка. Так по одному соединению проходят звук, запросы действий и их результаты без упаковки аудио в Base64.
Простые точные команды идут по короткому пути. Распознанное «дай лапу» связывается с намерением give_paw, а оно — с Hello, API 1016. Для этого не нужно ждать, пока языковая модель сочинит ответ и выберет функцию.

Сокращённый пример словаря и таблицы маршрутизации:
PHRASES = {
"сидеть": "sit",
"сядь": "sit",
"лежать": "lie_down",
"дай лапу": "give_paw",
}
SPORT = {
"sit": 1009,
"lie_down": 1005,
"give_paw": 1016,
}
Для «сядь» результатом будет sit и запрос в сервис sport с API ID 1009 и пустым JSON-объектом параметров. Русский текст не превращается в Python-код для исполнения.
Свободные вопросы передаются локальной Qwen 4B. Если для управления нужны параметры, используется отдельный типизированный вызов. Сам текст ответа модели командой для моторов не является.
Распознавание потребовало нескольких итераций. Первоначально использовали Vosk для коротких команд и T-one для разговоров. Позже основным финальным распознавателем стал GigaAM v3, перед ним — мягкий профиль шума кулеров, усиление и Silero VAD. Выяснилось, что маленький словарный распознаватель может принять постороннюю речь за ближайшую известную команду. Поэтому его предположение не исполняется преждевременно без проверки открытым распознаванием.
Получение чистого голоса из динамика тоже потребовало отдельной попытки. Наш вывод через DDS с кодированием Opus хрипел. Через штатный WAV-путь воспроизведение получилось чистым, его и оставили. Фильтры, сравнение распознавателей и устройство этого вывода подробно разберём во второй статье.
Оба наших сервиса работают на компьютерах собаки. Ноутбук нужен для разработки и диагностики. Для обычных команд и локального разговора внешняя сеть не участвует в вычислениях.
«Ко мне» не оказалось одним вызовом
Пока команда означает позу или жест, достаточно правильно выбрать штатную операцию. «Ко мне» добавляет вопрос: где находится человек?
У нас есть UWB-пульт. Робот получает оценку расстояния и направления до него. Значит, для этой задачи можно обойтись без карты помещения: цель находится относительно корпуса собаки. При этом Go2 идёт к пульту, который человек держит при себе. Распознавания личности или сопровождения по камере здесь нет.
Первым вариантом было штатное сопровождение. Но в наших проверках пульт оставался в режиме джойстика и восстановления стойки. Запись в DDS-переключатель не меняла реальный флаг включения. Нашли действующий RPC-сервис uwbswitch и стали проверять записанное состояние обратным чтением.
Одного переключения всё равно не хватило. Построили свой контур сопровождения: двадцать раз в секунду читаем свежие UWB-измерения, вычисляем линейную и угловую скорости и передаём их штатному сервису обхода препятствий.
На этой стадии получили особенно полезную неисправность: API принимал команды, собака стояла.
Для движения через этот путь понадобились три настройки: выбрать скорости из API, включить обход препятствий и включить Sport-режим FreeAvoid. Без последнего принятые запросы не приводили к ожидаемому движению.
В нашей прошивке получилась следующая связка:
Сервис | API ID | Назначение |
|---|---|---|
| 1004 | Выбрать команды скорости из API |
| 1001 | Включить обход препятствий |
| 2048 | Включить |
| 1003 | Передавать |
В этом контуре используем максимум 0,35 м/с вперёд и 0,65 рад/с на повороте. Зона завершения подхода — около 1,14 м по UWB-измерению. Это параметры нашего регулятора сопровождения.

Схема алгоритма, не запись траектории испытания. Для подхода отслеживаем расстояние и направление до пульта.
При цели за спиной возник ещё один эффект. Угол около границы +π и −π от небольшого шума менял знак. Регулятор начинал выбирать попеременно левый и правый разворот. Исправили сохранением выбранного направления, пока пульт не окажется в передней полусфере.
«Ко мне» завершает задачу после подхода. «За мной» продолжает следить за положением пульта до остановки. При пропаже свежих измерений передаём нулевую скорость; с возвращением данных сопровождение может продолжиться. Движение джойстика отменяет наш контур.
Для «стой» оказалось принципиально завершать источник следующих команд. Если только отправить StopMove, работающий цикл через пятьдесят миллисекунд снова задаст скорость. Поэтому сначала останавливаем задачу, обнуляем движение и возвращаем настройки источника управления и обхода, затем отправляем остановку.
И подход, и сопровождение проверили на настоящем роботе: Go2 подошла и остановилась; затем следовала за идущим человеком и остановилась по команде. Карта и интернет для этих двух сценариев не потребовались.
Что получилось на самой Go2
На борту работают русское распознавание, диспетчер команд, локальная Qwen 4B и синтез речи. Основной контроллер получает микрофон, воспроизводит звук и обращается к штатным API; Jetson выполняет обработку речи и разговорную часть.
Голосом проверены «дай лапу» и управление фонарём. Подход, сопровождение и остановка подтверждены в физических проверках. Остальной расширенный каталог имеет разную степень проверки: корректное сопоставление фразы и API не выдаём за испытание каждого трюка. Последние версии голосового моста и сопровождения ещё предстоит проверить полным выключением и включением собаки.
Полученные настройки вынесли в собственную панель: имя обращения, голос, характер, длина ответа и база знаний меняются отдельно. Профиль сохраняется на роботе, для обычной работы держать панель открытой не нужно. Для мероприятия можно подготовить другого агента со своими знаниями и отдельной историей разговоров.
Всё началось с желания дать Unitree Go2 команду по-русски. Для этого пришлось разобраться с двумя Linux-компьютерами, правами доступа, сетевым скриптом, аудиопотоком и форматами штатных вызовов. Теперь поверх этих частей есть собственный интерфейс управления и настройки собаки.
Следующая статья — о том, как Go2 слышит человека рядом с собственными вентиляторами и сколько времени проходит до первого звука ответа локальной LLM.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.