Клиент для LOOI: GATT на телефоне, одометрия и отдельный контур Ollama
Официальное приложение держит карту, зрение и личность в облаке. На теле корпуса этого нет: по Bluetooth доступны моторы, голова, свет, обрыв, короткий дальномер и заряд. Я пишу свой клиент com.looidev на Kotlin. Режима два. В первом телефон в руке и работает как джойстик. Во втором он закреплён на голове корпуса: там экран, передняя камера и цикл записи в GATT. Диалог, синтез речи и разбор консоли страницы выполняет Node-хост на компьютере в той же сети. В характеристику движения хост не пишет.
Ниже граница системы, канал корпуса, почему кадр движения 20 мс, как устроены ручной и автономный контуры, карта и правило «стена только от контакта», и как консоль страницы отделена от модели, которая разговаривает.
Граница
Корпус я не прошиваю и не обновляю. Канал последовательностей и OTA в клиенте нет: это другой класс команд, к езде по квартире он не относится.
Пространственное зрение и официальная идентификация владельца в теле тоже не живут. Локальный срез честный: передняя камера телефона, лицо считается на устройстве, снимок остаётся в файлах приложения. Облачную камеру и облачное имя я не подменяю.
Движение обязано переживать отсутствие хоста. Пока Ollama думает ответ, автономный цикл продолжает слать скорость. Если хост недоступен, вперёд он всё равно едет. Фраза «не понял» относится только к диалогу.
Декомпиляция
Было. На карточке LOOI нет руководства разработчика и SDK. Поставляется готовое приложение, протокол корпуса снаружи не описан.
Стало. Протокол я снял с официального клиента com.TangibleFuture.looiRobot версии 2.9.2. Вход — APK. Разбор делал через REA, https://rea.tools/ : пакет открывается как поставленный артефакт, классы и методы смотрятся по индексу, декомпиляция идёт по выбранному классу, а не выгрузкой всего дерева в редактор.
Тело корпуса — Kotlin, исходный файл TangibleBody.kt, в dex это classes5.dex, класс com.tf.devicemanager.impl.body.TangibleBody. Соединение собрано корутинами, поэтому подписка на notify в разборе называется не reallyConnect, а
com.tf.devicemanager.impl.body.TangibleBody$reallyConnect$1$onSuccess$afterSNRunning$1$7Из конструктора взяты строки UUID. Из move, toAngle и switchLight — формулы байтов: speed * 127, (1 - angle) * 255, лампа 1/0. Из вложенного onSuccess после серийного номера — порядок чтения FED5, FED6, FED8, FED4 и подписка notify на FED9 и FEF0.
Каталог поведения — не код, а assets того же APK: *_behaviors.json, app_setting.json, voiceRecognition.json. Они лежат в assets/looi и читаются как данные. Шаг с robotAction в этом каталоге есть. В пакет корпуса он не сериализуется.
В том же TangibleBody есть отправка последовательности и OTA (sendSequencePolling, genCommandPacket, разбор sector ack). Это я не переносил. Клиенту нужен канал скорости, головы, света, обрыва, TOF и заряда.
Стек
Контур | Где исполняется | Зависимость от сети |
|---|---|---|
GATT, стики, одометрия, обрыв, TOF, карта | телефон | Bluetooth до корпуса |
Глаза PAG | телефон, кэш с CDN | один раз на загрузку ролика |
Диалог, синтез, CDP, буфер Mac |
| LAN и интернет до TTS |
Мнения и реплики | Supabase проекта владельца | интернет; иначе локальный JSON |
Модели | Ollama на том же компьютере | localhost хоста |
Адрес хоста зашивается в APK на сборке: ipconfig getifaddr en0 и порт 8787. Ключ панели лежит в devtools-host/config.json и в репозиторий не входит. Параметры базы — только в devtools-host/.env, у каждого запуска свой проект.
Телефон: minSdk 26, compile/target 35. Хост: Node, без браузера внутри приложения.
Два режима
Было. Пульт и самостоятельная езда в официальном приложении — режимы одного клиента. В таблице совместимости складные телефоны не рекомендуются, внешнего экрана крышки там нет.
Стало. Я развёл джойстик и закреплённый телефон. На Galaxy Z Flip второй режим продолжается в сложенном виде через виджет крышки.
Первый режим — джойстик. Телефон не закреплён, включён Manual, в GATT уходят только стики. Автономный цикл и обычная речь в этом режиме отрезаны.
Второй режим — телефон на голове. Крепление магнитное, на одну панель. Manual выключен, дальше работает автономный цикл: камера, карта, речь, взгляд вниз.
Если телефон — Galaxy Z Flip, во втором режиме его можно сложить и пользоваться внешним экраном. На крышку ставится виджет. Это не отдельное приложение и не плеер: виджет показывает кадр текущей реакции глаз, снимок PAG на прогрессе 0.35, файл cacheDir/cover-eyes.png. Пока на крышке открыта activity, глаза там живые. Уже идущая сессия при складывании не останавливается.
Вертикальный экран
Было. Официальный клиент собран как настольный компаньон. Вертикальной раскладки под телефон на голове в нём нет.
Стало. Портрет — основной кадр. Поворот идёт за датчиком, статус и карта не уезжают под боковую рейку.
Во втором режиме телефон на голове стоит вертикально, поэтому портрет — основной кадр, а не повёрнутый ландшафт.
screenOrientation = fullSensor. Фиксированный landscape я не ставлю: ориентация следует за датчиком. При повороте перекладывается только хром.
Портрет: строка статуса (эквалайзер и процент) сверху справа. Карта снизу справа, выше полосы распознанной фразы и рейки 84 dp. Ландшафт: та же строка статуса сверху слева, рейка 80 dp прижата к концу экрана, у карты marginEnd 96 dp, у верхних кнопок marginStart 148 dp, иначе рейка закрывает процент. Системные панели контент не перекрывают.
Пока GATT не в состоянии connected, вместо глаз центр экрана занимает кнопка Connect. После соединения возвращаются глаза. Ролики — Tencent PAG по официальным URL, на устройстве кэш, в APK я их не вкладываю.
В автономном режиме голова смотрит вниз: look(0.92). Центр — 0.5, вверх — 0.08. Пока идёт подъезд к лицу, побег от обрыва или разговор после пробуждения, вниз я голову не давлю.
Ручной контур
Manual — отдельный флаг, не пункт меню «исследовать».
Включён: в GATT уходят только стики. Автономный цикл и обычная речь отрезаны (mayTalk == false). Выключен: тот же цикл, что и исследование. Отдельной кнопки Explore нет. Голосовые «гуляй» и «спи» по-прежнему выбирают режимы, но исследование с рейки убрано.
Стик и автономный ход делят один повторитель. Прошивка скорость не защёлкивает: один пакет FED0 даёт примерно 1–2 см и корпус останавливается. Поэтому и стик, и автономная команда повторяются каждые 20 мс.
Очередь GATT одна. Запись уходит с HandlerThread looi-wheels. Повтор кадра — с HandlerThread looi-hold. Повторный drive того же UUID выкидывает предыдущий ещё не ушедший пакет, а не копит их. Для движения предпочтителен WRITE_TYPE_NO_RESPONSE, пауза 12 мс. Остальные записи — WRITE с паузой 40 мс, если write-without-response нет.
Нулевая скорость снимает удержание и шлёт drive(0, 0). Ненулевая запускает удержание один раз, дальше меняются только heldForward и heldVeer.
Канал корпуса
Сервис 000000ff-0000-1000-8000-00805f9b34fb. Характеристики с тем же базовым UUID:
UUID | Назначение | Полезная нагрузка |
|---|---|---|
| скорость |
|
| голова | скорость |
| лампа |
|
| стик телефона | чтение раз в 400 мс, если очередь пуста |
| обрыв | чтение при подключении |
| заряд | байт процента, опрос раз в 15 с |
| notify | тип в первом байте, см. ниже |
| лог | notify |
Положительный veer — влево. Лицо в правой половине кадра даёт отрицательный veer.
FED9, которые я разбираю: 1 — обрыв (4 байта, 0x00 это край); 8 — процент; 11 — док (1 значит зарядка); 14 — TOF; 5/6 — стик установлен или снят; 9/10 — касание; 18 — кнопка. Тип 7, колбэк последовательности, не использую.
Обрыв реагирует на сырой 0x00 сразу: лампа и задний ход command(-0.7, ±0.55), пока байт не очистится. Подтверждение для лица и для «ума» — те же 600 мс, что и раньше. На движение эти 600 мс не распространяются: на чёрный стол вперёд ждать нельзя.
TOF я не считаю стеной по абсолютному миллиметражу. Пол запоминается. Удар носом — три выборки подряд, где дистанция меньше 90 мм и минимум на 30 мм короче выученного пола. mm <= 0 стеной не является. Если пол уже выучен и при ходе быстрее 0.4 три тика подряд дают невалидную дистанцию, это чёрная поверхность перед носом, не «датчик молчит».
Заряд: речь на пороге ниже 20% и ещё раз ниже 10%, даже в ручном режиме. На док он сам не едет. Останов колёс по зарядке есть только если включён зарядный контур или корпус прислал док, тип 11.
Модули, а не один цикл
Было. На сайте поведение описано как одна система: восприятие, решение, анимация и механика склеены и поставляются вместе.
Стало. Глаза и голос идут по каталогу, колёса — отдельным циклом, диалог и консоль — разными моделями, память — базой пользователя.
Автономный тик разделён.
MindBook.stepберёт один шаг официальной цепочки поведения, не разворачивая её целиком. На шаге исполняются глаза, жужжание и голосовая строка. Следующий код ставится в ожидание на длительность ролика, но не дольше 14 с и не короче 4 с.Исполнитель
robotAction(performer 2) отбрасывается. В пакет корпуса он не превращается. Хореография колёс из записей поведения мне не нужна: её тайминги рассчитаны на другой канал.Колёса остаются у локального цикла: прямой ход, объезд по карте, побег, обрыв. Поведение лица этот цикл не останавливает и не подруливает.
Диалог ходит в
POST /api/thinkи обязан вернутьact: "stay". Даже если модель пришлёт другой акт, хост для разговора акт затирает.Память читается и пишется на хосте. Телефон адреса Supabase не знает.
Пустой ответ модели или упавший хост не переводят колёса в halt.
Консоль
Было. Официальный LOOI остаётся настольным собеседником: ездит, смотрит, разговаривает. Канала к рабочей вкладке браузера и разбора её ошибок там нет. Полезность кончается развлечением на столе.
Стало. Консоль — первый рабочий модуль. Робот остаётся на столе, но читает консоль уже открытой страницы. Помощник на Ollama, модель Qwen, разбирает эти логи и возвращает причину и правку. Следующие рабочие модули можно добавить тем же разрезом: отдельный контур на хосте, без права писать в характеристику движения.
Это не WebView и не встроенный Chrome. Страница уже открыта на компьютере, в том браузере, где идёт работа. В поле хоста страницы уходит POST /api/cdp/connect с pageHost. Хост смотрит GET {cdp}/json/version и GET {cdp}/json/list, переписывает webSocketDebuggerUrl на свой CDP-базис и делает Target.attachToTarget.
На телефоне RecyclerView показывает поток /api/events и /api/console. Повторное открытие экрана подтягивает историю и стрим. Повторный CDP-connect при этом не выполняется.
Кнопка Brain вызывает помощника. Телефон шлёт POST /api/analyze, хост собирает последние строки консоли — уровень, текст, URL и номер строки, не больше 12 КБ — и отдаёт их в Ollama, POST {ollama}/api/chat. Адрес Ollama берётся из config.ollamaBaseUrl, по умолчанию http://127.0.0.1:11434. Модель — поле config.model, по умолчанию qwen2.5-coder. Это не модель разговора: котёнок в этот запрос не подставляется и колёса из него не получает.
Системная инструкция помощника узкая: объяснить ошибку консоли и предложить конкретную правку. В ответе три части: причина, где смотреть, что менять. Кадры стека, которых нет в логе, дописывать нельзя. Если консоль пустая, так и сказать. Ответ стримится на телефон событиями token. Инструкция с кнопки фиксированная: разобрать ошибки консоли.
Контур тестовый и намеренно узкий. Нужен Chrome с удалённой отладкой на компьютере, где крутится хост. Страницу на экран робота я не переношу. Этого минимума хватает, чтобы корпус перестал быть только жужжащей игрушкой рядом с ноутбуком.
Карта
Было. Обнаружение края и препятствий в официальном клиенте закрытое. Локальной сетки с координатами, которую можно стереть и заново накатать, там нет.
Стало. Сетка считается на телефоне: конус камеры, короткий TOF, обрыв и одометрия. Стена пишется от контакта, не от картинки комнаты.
Сетка занятости: UNKNOWN, FREE, OBSTACLE, DROP. Источники:
свободный конус с камеры головы;
короткий TOF корпуса;
обрыв;
одометрия от команд, не от энкодеров. Энкодеров на этом канале нет.
Интеграция позы:
dt = clamp(dt, 0.05, 0.35)
poseTheta += veer * 1.15 * dt
translation = forward * 0.18 * dtУгол обновляется всегда. Сдвиг по XY выключается, только если команда едет, кадр камеры не меняется и счётчик такого состояния дошёл до 6. Замерший кадр сам по себе клином не считается: иначе на ровном светлом полу поза замирает, а корпус едет.
Стена пишется бампером. traceWall вызывается на тиках контакта и удлиняет след: лоб — дуга 0.18 м, бок — боковой штамп плюс дуга. Это не отрезок, поставленный по силуэту комнаты в кадре. Большая комната в объектив стены не рисует. Короткий стабильный TOF тоже не рисует: это пол, пока он не просел относительно базовой линии.
Клин: копится пройденный по команде путь sentForward * 0.036 м на тик. Условие — больше 0.8 м команды при сдвиге позы не больше 0.08 м, и только вместе с контактом (нос, обрыв или сырой край). Речь «уткнулся» без контакта не произносится. После схода с угла 8 с покоя, чтобы несколько сантиметров не запускали новый разворот.
Чистый пол в автономном режиме — command(1, 0) каждый тик, без паузы между пакетами. Раз в 24–38 с цикл останавливается примерно на 1.5 с и произносит одну короткую метку. Это пауза поведения, не логика объезда.
Обрыв и нос перебивают прямой ход. Побег: один задний ход, потом разворот на месте, затем охлаждение носа. Тупик объявляется один раз, когда попытки выхода кончились или карта закрыта и объезда нет. Флаг сбрасывается, когда контакт пропал или пол впереди снова открыт. Поиск пути при этом не останавливается.
Карта пишется на диск. Долгое нажатие её стирает. Если корпус поднять, одометрия врёт: привязки к внешним маякам нет. Это не лидар и не облачный SLAM.
Диалог и две модели
Было. Разговор и память официального LOOI уходят контрактному облачному ИИ. Модель пользователь не выбирает. Для Android на карточке товара указаны английский и китайский.
Стало. Две локальные модели в Ollama: одна говорит, вторая читает консоль. Диалог на русском, характер и консоль переключаются отдельно.
Пробуждение: «Hi LOOI» и соседние формы, включая строки из официального app_setting.json. Кнопки «слушать» нет. Распознавание ждёт активацию по VAD, фраза копится целиком, команда исполняется после паузы около 1.6 с. Частичные результаты команду не запускают. Для кириллицы строка приводится к нижнему регистру до сравнения: флаг (?i) в Android не включает UNICODE_CASE.
Пока синтез говорит, микрофон удерживается, иначе корпус слышит сам себя. Поток распознавания в цикле из-за этого не перезапускается.
После пробуждения окно около 50 с. Следующая фраза в этом окне новое пробуждение не требует. «Пока», «до свидания», «замолчи» окно закрывают. Команды движения из этого окна по-прежнему команды, а не реплики в модель.
Реплика без команды уходит на хост как POST /api/think с talk: true и текстом последних реплик. Системный промпт требует своё мнение, ответ на последнюю фразу и одну вставку «Мурр» внутри текста, не вместо ответа. Ответ — JSON:
{"say":"...", "act":"stay", "hold":""}say произносится. hold, если не пустой и ещё не записан, становится мнением. act для этого запроса принудительно stay. На телефоне, если в строке нет мурлыканья, префикс «Мурр.» добавляется локально. На время реплики голова ставится в 0.5, глаза играют подмигивание. Взгляд вниз на эти секунды отключён.
Синтез: POST /api/say возвращает audio/mpeg. Хост вызывает python3 -m edge_tts. Русский голос ru-RU-SvetlanaNeural, rate +12%, pitch +52 Hz. Английский — en-US-AnaNeural. Кэш до 48 фраз. Если нейросеть недоступна, локально say и ffmpeg с переносом высоты. Если и это не собралось, на телефоне Google TTS, женский голос, pitch 1.48. Системный голос Samsung для русской локали мужской, поэтому язык не выставляется после выбора голоса: этот вызов сбрасывает голос на умолчание.
Модели разведены в UI и в хосте.
Мозг:
body.model, иначеconfig.brainModel, иначеgranite4.1:3b. Пустое поле в приложении означает дефолт хоста. Смена в Models или Personality пишется в предпочтения телефона и уходит в следующий/api/think.Консоль:
config.model, по умолчаниюqwen2.5-coder. Список обоих берётся изGETOllama/api/tagsчерез хост.num_predictу разговора 320, у коротких сигналов поведения 80. Разговор не должен звучать как односложные метки езды, а метки езды не должны превращаться в абзац.
Пока Manual включён, автономная речь молчит. Ответ на произнесённую фразу и предупреждение о заряде этот флаг обходят: иначе диалог и низкий заряд существуют только в автономном режиме. Колёса фраза всё равно не получает.
Память
Было. Память официального LOOI обрабатывается вместе с историей разговора на стороне облачного ИИ. Подключить свою базу нельзя.
Стало. Память лежит в Supabase того, кто запустил хост. Чужой проект подключается своим .env и теми же двумя таблицами. Без базы хост пишет локальный JSON.
Память диалога — отдельный контур на хосте. Перед ответом хост читает из базы уже сказанные мнения и последние реплики и подставляет их в POST /api/think. После ответа дописывает новую реплику и, если модель вернула непустое hold, новое мнение. Телефон адреса базы не знает. Цикл GATT эту базу не ждёт: таймаут чтения 4 с, при ошибке хост берёт локальный memory.json и всё равно отдаёт фразу.
База не общая. Каждый поднимает свой проект Supabase и указывает его хосту. В devtools-host/.env две переменные, в config.json и в APK их нет:
SUPABASE_URL=https://YOUR\_PROJECT.supabase.co
SUPABASE_KEY=sb_publishable_your_keySUPABASE_KEY — публичный ключ этого проекта. Секретный ключ и пароль Postgres в клиент не ставятся. Если переменных нет, мнения и реплики остаются в memory.json на компьютере.
В своём проекте, в SQL Editor, создаются две таблицы:
looi_opinions(text unique, created_at)— мнения, в промпт идут последние 24;looi_turns(who, text, created_at)— репликиOwnerиLOOI, в промпт идут последние 40.
RLS включён, ролям anon и authenticated нужны select и insert. Другой человек подключает свою базу тем же способом: свой .env и те же две таблицы у себя. Данные разговоров между проектами не смешиваются.
Что ещё завязано на хост и не завязано на колёса
Пока корпус подключён, хост принимает текст буфера (POST /api/clip → pbcopy) и короткие уведомления фраз (POST /api/note → osascript). Дубликат фразы фильтруется 8 с. Содержимое буфера в лог хоста не пишется.
Процент на экране обновляется с FED8 и с notify типа 8. Подпись без имени продукта: 42% или --. Эквалайзер в той же плашке включается только на время live распознавания.
Угол
Угол видно не по картинке стены. Он продолжает движение, а координаты не меняются. Команды на колёса идут, корпус толкается вперёд, а точка на карте стоит на том же месте. Значит, он никуда не проезжает. Он толчётся в стенку. На практике это угол: впереди и сбоку уже занято, и он крутится на одних и тех же координатах.
Если он так задерживается, его оттуда надо вывести. Я запоминаю эту точку. Пока он остаётся на ней и при этом всё ещё пытается ехать, он сдаёт назад и разворачивается, пока координаты не станут другими. Выехал с этого места — угол пройден. Сразу новый разворот я не начинаю: иначе пара сантиметров, и он снова топчется там же.
Разработка продолжается. Скоро проект будет на GitHub. Оттуда новую прошивку для робота LOOI можно будет поставить всем.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.