grig-teo:~$
← ко всем статьям

Разговор с умным кольцом без инструкции: реверс-инжиниринг моего COLMI R10

Неверные догадки о протоколе, соединение, которое врало, катастрофы с endianness, шаги-призраки и датчик температуры, спрятанный в health-check потоке — как я превратил недокументированное кольцо за $30 в живой пайплайн здоровья.

Разговор с умным кольцом без инструкции

Мне нужны были показатели здоровья — пульс, кислород в крови, стресс, сон, шаги, температура — непрерывно текущие с кольца COLMI R10 за $30 в моё приложение, на мой сервер и на публичную страницу здоровья этого сайта. COLMI не публикует ни SDK, ни документацию по протоколу, а мейнстримные фитнес-приложения не умеют читать кольцо, потому что оно не использует стандартный Heart Rate Service. Единственный путь — реверс-инжиниринг.

Первый контакт

На бумаге всё просто: кольцо говорит через кастомный сервис в стиле Nordic UART с двумя характеристиками — одна на запись, вторая на уведомления. Первый клиент я написал на оптимистичных предположениях, подсмотренных в заметках по реверсу соседней модели: 16-байтовый пакет, байт команды, контрольная сумма. Каждый отправленный пакет игнорировался — потому что я ошибся сразу в трёх местах: команда стояла не по тому смещению, модуль контрольной суммы был неверным, а опкоды я выдумал. Кольцо отвечало тишиной — единственный ответ, который железо даёт на неправильный вопрос.

Лекарство — смирение. Я перестал гадать и прочитал три года чужой работы: Python-проект colmi_r02_client, справочник протокола colmi.puxtril.com и классы поддержки COLMI в Gadgetbridge. Настоящий кадр: опкод в нулевом байте, четырнадцать байт полезной нагрузки, контрольная сумма — сумма первых пятнадцати байт по модулю 256. Первым правильным пакетом был запрос батареи, и в ответ пришло 57%. Я смотрел на это число, как на письмо от устройства.

Соединение, которое врало

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

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

Ещё одно правило джунглей: кольцо обслуживает только одного хозяина. Каждый раз, когда соединение держал QRing, моё сканирование крутилось вхолостую. Теперь приложение замечает это, просит закрыть другое приложение и перезапускает сканирование каждые две минуты, пока кольцо не освободится.

Учим кольцо говорить обо всём

Когда база заработала, я разметил весь словарь: потоки пульса и SpO2 в реальном времени; 15-минутные слоты активности — шаги, калории, дистанция; 30-минутные логи стресса и HRV; почасовая история SpO2; и сессии сна — последние два через второй, «big data» BLE-сервис, чьи кадры приходят порезанными на уведомления и требуют склейки, прежде чем что-то значить.

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

Каждая единица врёт, пока не доказано обратное

Самые опасные баги — не падения, а правдоподобные числа. Счётчик шагов читался little-endian вместо big-endian, и 43 шага превращались в 2,8 миллиона. История калорий умножалась на десять, хотя кольцо отдаёт санти-калории: один слот на 23 шага гордо рапортовал 1430 ккал — дневной рацион, сожжённый за пятнадцать минут неподвижности. Живой итог калорий кольца включает базовый метаболизм, поэтому карточка к полудню тихо показывала расход за весь день. А на сервере каждая синхронизация раз в три минуты дописывала те же 15-минутные слоты заново, раздувая шаги в восемь раз — пока база не научилась upsert вместо append.

Моё правило теперь: не верь единице, пока она не проверена физикой. Триста шагов — это примерно 200 метров и около 12 ккал. Если математика ломает реальность — ошибка в декодере, а не в мире.

Температура, спрятанная в health check

QRing показывал температуру тела — 36,7 °C, но нигде не было сказано, как её получить. Я прощупал все недокументированные типы данных — кольцо вежливо возвращало пустые кадры. Прорыв пришёл, когда я запустил поток «health check» и просто стал смотреть на байты. Кадр за кадром: один байт — мой пульс, два байта — интервалы, совпадающие с ним до миллисекунды, а посередине — big-endian значение в миллиградусах: 32,85 °C, медленно остывающее вечером. Температура кожи — ровно там, где физика и говорит, что ей быть. 36,7 в QRing — это та же температура кожи плюс поправка на температуру тела. Датчик никогда не прятался — пряталась документация.

Что ещё не решено

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

Что я забираю из этого проекта: протокол — это разговор, а не документ. Задаёшь вопрос, устройство отвечает байтами, и каждое неверное предположение стоит дня тишины. Но первый кадр, который парсится — первые честные 57% — ощущается так, будто устройство решило довериться тебе в ответ.

Разговор с умным кольцом без инструкции: реверс-инжиниринг моего COLMI R10 | grig-teo