Реверс-инжиниринг Xiaomi Smart Band 10

от автора

Дисклеймер: Вся работа проводилась исключительно в исследовательских целях в отношении данных собственной учетной записи. Статья носит аналитический характер, не содержит призывов к получению несанкционированного доступа к чужим системам, рабочих сессионных токенов, ключей шифрования или готовых инструментов для обхода систем безопасности третьих лиц.

У носимых устройств есть парадокс: браслет измеряет ваши пульс, сон и активность, но готового открытого API для интеграции этих данных в сторонние системы (например, домашний мониторинг или локальную БД) производитель не предоставляет. Официальное приложение Xiaomi Mi Fitness показывает красивые графики, но данные остаются «запертыми» внутри мобильной экосистемы.

Изначальная задача была чисто прикладной: настроить автоматический сбор данных о здоровье в локальную SQLite-базу и выводить отчеты в семейный Telegram-бот. Поскольку браслет синхронизируется с приложением, а то в свою очередь с облаком Xiaomi, данные гарантированно передаются по сети. Нужно было понять, в каком формате они передаются и как их забрать.

Эта статья — технический разбор пути от анализа сетевого трафика и настройки доверия к собственному CA до реверс-инжиниринга RC4-протокола Xiaomi, расшифровки AES/CBC-объектов из хранилища FDS и парсинга проприетарного бинарного формата сна.

TL;DR (Что в итоге получилось)

  • Исследование: Разобран сетевой протокол взаимодействия мобильного приложения Mi Fitness с облаком Xiaomi Health Cloud.

  • Криптография: Реализован декрипт RC4-транспорта (с отбрасыванием первых 1024 байт keystream) и AES/CBC расшифровка бинарных слепков данных.

  • Формат: Написан парсер бинарных структур Xiaomi FDS для извлечения детальных графиков сна, пульса и кислорода в крови (SpO2).

  • Практика: Открыт исходный код на GitHub и упакован в два Docker-контейнера (синхронизатор с SQLite + Telegram-бот для интерактивного доступа).

Окружение эксперимента

Для тех, кто захочет повторить или изучить детали, тесты проводились в следующих условиях:

Компонент

Значение / Версия

Приложение

Xiaomi Mi Fitness (Android) v3.55.0i

Устройства

Xiaomi Smart Band 10

Аккаунт

Регион: RU (Russian IDC)

Дата аудита

Май 2026 года

Стек сервиса

Python 3.11, SQLite, Docker, Aiogram

Сначала карта местности

Перед тем как ломиться в конкретные endpoint-ы, полезно нарисовать систему. В нашем случае цепочка взаимодействия выглядит следующим образом:

Карта закрытой системы

Карта закрытой системы

На бумаге всё просто. Браслет отдаёт данные приложению. Приложение отправляет их в облако. Облако хранит часть данных в обычных JSON-ответах, а часть — в объектном хранилище FDS. Наш сервис должен повторить достаточно большую часть поведения приложения, чтобы получить данные без телефона в руках.

Первой идеей был прямой Linux-sync с браслетом. Для старых Mi Band в интернете есть проекты через BLE, для Gadgetbridge есть поддержка многих моделей, но Mi Band 10 и новые протоколы быстро делают этот путь дорогим. Нужно получать auth key, бороться с BLE-особенностями, поддерживать протокол устройства. Для домашнего мониторинга это слишком широкий фронт.

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

Первый тупик: iOS, Stream и пустые CONNECT

Начал с самого очевидного: перехватить трафик официального Mi Fitness на iPhone. Для iOS есть удобные приложения вроде Stream: они поднимают локальный VPN/HTTPS-прокси и умеют экспортировать HAR.

Запустил перехват, открыл Mi Fitness, синхронизировал браслет. В HAR действительно появляются записи. Но радость короткая:

CONNECT ru.hlth.io.mi.com:443status: 0content: { size: 0, text: "" }

Таких строк много. Видно, что приложение ходит на ru.hlth.io.mi.com, но внутри туннеля пусто. Прокси видит, что машина подъехала к воротам, но не видит, что лежит в багажнике.

Причина — SSL pinning. В обычном HTTPS клиент доверяет центрам сертификации из системного хранилища. MITM-прокси добавляет свой сертификат, система говорит “сертификат доверенный”, и трафик можно расшифровать. Но мобильное приложение может быть строже: оно заранее знает отпечаток сертификата сервера Xiaomi и проверяет именно его.

Это похоже не на проверку паспорта по базе, а на проверку по заранее запомненному отпечатку пальца. Stream показывает приложению свой сертификат, iOS может ему доверять, но Mi Fitness говорит: “отпечаток не тот”, и соединение не раскрывается.

Почему MITM на iOS показал только CONNECT

Почему MITM на iOS показал только CONNECT

На iOS без jailbreak анализировать такой трафик неудобно. Можно поставить сертификат, включить HTTPS sniffing, добавить домены, но если приложение использует строгую проверку сертификатов, HAR всё равно останется набором CONNECT.

Значит нужен Android.

Android без root: готовим исследовательскую сборку

Важная развилка: Android-устройство для наших целей не обязательно рутовать. Если нет возможности заставить приложение доверять локальному центру сертификации (CA) во время выполнения, можно подготовить тестовую сборку приложения: перепаковать APK так, чтобы оно принимало пользовательские сертификаты и позволяло проводить анализ трафика в контролируемой среде.

Скачал Mi Fitness с APKMirror. Вместо одного .apk приехал современный .apkm bundle: внутри base.apk и набор split APK под архитектуру, язык и плотность экрана.

unzip com.xiaomi.wearable_3.55.0i-...apkm -d mi_fitness_extractedls mi_fitness_extracted

Внутри были, среди прочего:

base.apksplit_config.arm64_v8a.apksplit_config.ru.apksplit_config.xxhdpi.apksplit_config.en.apk

Дальше пошёл обычный Android-исследовательский pipeline: apktool, network security config, подпись, установка split APK. Но без приключений не вышло.

Автоматический apk-mitm упал на сборке:

[Fatal Error] :1107:143: The entity name must immediately follow the '&' in the entity reference.AndroidManifest.xml:1107: error: not well-formed (invalid token).

Смысл ошибки простой: в манифесте приложения оказался символ &, который XML-парсер хотел видеть как &. Официальная сборка Xiaomi с этим живёт, а строгий apktool отказывается собирать обратно.

Пришлось идти вручную:

apktool d mi_fitness_extracted/base.apk -o mi_fitness_decoded

Затем добавил или заменил res/xml/network_security_config.xml так, чтобы приложение доверяло пользовательским сертификатам:

<?xml version="1.0" encoding="utf-8"?><network-security-config>    <base-config cleartextTrafficPermitted="true">        <trust-anchors>            <certificates src="system"/>            <certificates src="user"/>        </trust-anchors>    </base-config></network-security-config>

В AndroidManifest.xml у приложения уже была привязка:

android:networkSecurityConfig="@xml/network_security_config"

После этого APK собрался:

apktool b mi_fitness_decoded -o mi_fitness_patched.apk

Но Android не установит произвольный APK без подписи. А split APK должны быть подписаны одним и тем же ключом. Если подписать только base.apk, установщик выдаст ошибку про несовместимые подписи. Если подписать старым jarsigner, Android может ругнуться на отсутствие Signature Scheme v2.

Потребовались apksigner and zipalign (ниже приведен путь для macOS с установленным Homebrew Android SDK; для Linux и Windows пути будут отличаться, а если утилиты добавлены в PATH, достаточно вызвать просто apksigner):

# Для macOS Homebrew путь выглядит так. В Windows/Linux укажите ваш путь к Android SDK build-tools.APKSIGNER=/opt/homebrew/share/android-commandlinetools/build-tools/34.0.0/apksignerfor apk in mi_fitness_patched.apk \  mi_fitness_extracted/split_config.arm64_v8a.apk \  mi_fitness_extracted/split_config.ru.apk \  mi_fitness_extracted/split_config.xxhdpi.apk \  mi_fitness_extracted/split_config.en.apk; do  "$APKSIGNER" sign --ks my.keystore \    --ks-pass pass:<LOCAL_PASSWORD> \    --key-pass pass:<LOCAL_PASSWORD> \    "$apk"done

На этом этапе важно не романтизировать процесс. Это была не красивая “одна команда и всё заработало” история, а серия вполне бытовых ошибок: не тот путь до apksigner, подписи v1 вместо v2, split APK с разными подписями, попытка поставить один base.apk на ARM64-телефон. Но итог был нужный: исследовательская сборка Mi Fitness успешно запустилась на контролируемом Android-устройстве без root.

Первый настоящий перехват

После установки patched APK схема стала обычной:

Android phone -> Wi-Fi proxy -> mitmproxy on Mac -> Xiaomi Cloud

На телефоне я установил CA mitmproxy через http://mitm.it, в Wi-Fi прописал прокси на IP Mac и порт 8080. На Mac запустил mitmweb со скриптом, который сохраняет запросы и ответы по нужным доменам.

В этот момент впервые появились не пустые CONNECT, а реальные HTTPS-запросы:

ru.hlth.io.mi.comsts-hlth.io.mi.comru.watch.iot.mi.comaccount.xiaomi.com

Среди путей были:

/healthapp/privacy/get_privacy_change/app/v1/data/get_aggregated_fitness_data_by_watermark/app/v1/data/get_project_data_by_time/app/v1/statistics/get_stat_data_by_time/app/v1/eco/api_proxy/healthapp/service/gen_download_url/healthapp/user/get_miot_user_profile

И главное: в заголовках появились рабочие cookies:

Cookie: cUserId=<REDACTED>; serviceToken=<REDACTED>; userId=<REDACTED>User-Agent: Android-16-3.55.0i-...

Наивно кажется, что всё закончилось. Токен на руках, endpoint-ы тоже, можно писать Python-клиент.

Нет.

Почему простой curl получил 401

Первый тест был максимально прямолинейным: взять serviceTokencUserIdUser-Agent и повторить один из запросов через curl.

curl -i -X POST \  'https://ru.hlth.io.mi.com/healthapp/privacy/get\_privacy\_change?locale=ru\_by' \  -H "Cookie: cUserId=<REDACTED>; serviceToken=<REDACTED>; locale=ru_by" \  -H "User-Agent: Android-16-3.55.0i-..." \  -H "Content-Type: application/json; charset=utf-8" \  --data '{}'

Ответ:

{"code":0,"message":"auth err"}

Это был важный поворот. Токен из перехвата был живой: само приложение с ним ходило. Но Xiaomi Health API не принимал “голый” JSON-запрос. В реальных запросах приложения были параметры:

_noncedatasignaturerc4_hash__ssecurity

То есть поверх HTTPS был ещё один прикладной криптографический слой. HTTPS защищал транспорт, а Xiaomi дополнительно шифровала тело запроса и ответа на уровне собственного протокола.

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

RC4-слой: ищем ключ в APK

Дальше телефон был уже не нужен как интерактивный инструмент. На руках был APK, зашифрованные request/response, _nonce и ssecurity. Значит надо было понять, как приложение само шифрует и расшифровывает эти данные.

Я декомпилировал APK через jadx и начал искать строки:

rg -n "rc4_hash__|_nonce|ssecurity|security|RC4|decrypt|encrypt" jadx_mifitness

Ключевой класс нашёлся в коде Xiaomi SmartHome:

SmartHomeRc4Api.javaRC4DropCoder.java

Смысл алгоритма оказался таким:

key = sha256(base64decode(ssecurity) + base64decode(_nonce))plaintext = rc4_drop1024(ciphertext, key)

drop1024 означает, что первые 1024 байта сгенерированного RC4 keystream просто отбрасываются и не используются для шифрования. В криптографии это классический способ защиты от атак на слабые ключи (weak key attacks), которые могут быть восстановлены по начальным байтам потока. На практике: если не отбросить 1024 байта, дешифрованный результат превращается в мусор.

Первая версия скрипта расшифровала только несколько request-ов. Это уже было доказательством, что направление верное:

{"eco_api":"/bs/bind/devices","params":"{\"page\":1,\"pageSize\":50,\"status\":1}"}

Потом я поправил обработку base64, gzip и порядок операций. В итоге удалось расшифровать:

106 request payloads58 response payloads
Лестница протокольных слоёв

Лестница протокольных слоёв

Для response рабочая схема выглядела так:

HTTP response body-> gzip layer from mitmproxy/raw body-> base64 decode-> RC4 drop1024 with sha256(ssecurity + _nonce)-> JSON

И здесь случился ещё один полезный урок: зашифрованный API редко ломается одним правильным предположением. Иногда алгоритм уже найден, но остаётся порядок упаковки: сначала gzip или потом gzip, base64 URL-safe или обычный base64, где брать nonce, распаковал ли тело сам proxy.

JSON есть, но нужных деталей сна в нём нет

После RC4 я наконец увидел нормальные JSON-ответы. Там были профили, часть статистики, ответы health API. Но детальные ночные измерения пульса и SpO2 не лежали прямо в этих JSON как массивы вида [{time, value}].

Часть endpoint-ов возвращала пусто или “не поддерживается в текущем IDC”:

{"data_list":[],"has_more":false,"watermark":0}
{"code":-6,"message":"unsupport in current IDC","result":null}

А вот endpoint /healthapp/service/gen_download_url оказался куда интереснее. В его расшифрованном ответе были поля:

{  "result": {    "<suffix>_<timestamp>": {      "url": "<SIGNED_FDS_URL>",      "obj_name": "<FDS_OBJECT_NAME>",      "obj_key": "<REDACTED>"    }  }}

Это означало, что детальные данные лежат не в обычном API-ответе, а отдельным объектом в Xiaomi FDS.

FDS здесь можно воспринимать как склад коробок. Health API не отдаёт саму коробку, он выписывает временный пропуск: signed URL. С этим пропуском можно один раз сходить на склад и забрать объект. Но внутри объекта, как выяснилось, лежит ещё одна упаковка.

FDS-файл: длинная строка вместо JSON

Скачанный FDS-объект выглядел как обычный текстовый файл длиной около 43 KB:

nBfra1HbEwa_dzNGuWmTVNXzJ-U-kDxS...

file определял его как:

ASCII text, with very long lines, with no line terminators

Это был не JSON. Не protobuf. Не gzip в чистом виде. Просто длинная base64/base64url-строка.

Поиск по APK снова дал ответ. В коде нашёлся FitnessFDSUploader, где логика была почти прямым комментарием к этой проблеме:

downloadFromFDSdownload: aes decrypt faileddownload objectKey is null

А затем стало видно главное:

new AESCoder(objectKey).decrypt(downloadedText)

Открываем AESCoder, и слой становится понятным:

AES/CBC/PKCS5PaddingIV = "1234567887654321"key = Base64.decode(obj_key, 8)data = Base64.decode(downloadedText, 11)

В Python это свелось к такой идее:

key = android_base64_decode(obj_key, flags=8)ciphertext = android_base64_decode(downloaded_text, flags=11)cipher = AES.new(key, AES.MODE_CBC, b"1234567887654321")plaintext = unpad(cipher.decrypt(ciphertext), 16)

Результат AES-дешифровки я сохранил в локальный файл pkcs7.bin (назвал его так в честь используемого дополнения блоков PKCS#7 padding). Файл получился размером около 32 KB. Это был уже не шифртекст. Но и не JSON.

Бинарный формат: коробка без этикеток

Первые байты расшифрованного файла выглядели так:

94 b5 0f 6a 0c 05 00 df c0 01 ...

If you read the first four bytes as a little-endian integer, you get a timestamp:

0x6a0fb594 -> 1779414420

Следующий байт:

0c -> 12

Для Москвы UTC+3 это ровно 12 интервалов по 15 минут. То есть timezone в этом формате хранится не в секундах, а в четвертях часа.

Дальше:

05 -> version 500 -> type marker

Это уже было похоже на структуру из APK:

FitnessDataIdFitnessDataHeaderFitnessDataParser
Бинарный формат Mi Fitness

Бинарный формат Mi Fitness

Важная мысль: бинарный blob — это не обязательно “зашифрованный мусор”. Часто это просто компактная структура без полей и имён. Если найти код, который её читает, она превращается в обычные записи.

По декомпилированному коду и экспериментам удалось восстановить формат all-day sleep:

bytes 0..3   timestamp, little-endianbyte  4      timezone in 15-minute unitsbyte  5      versionbyte  6      type markerbytes 7..8   data_valid bitsetthen         report fieldsthen         assist-info arrays: HR, SpO2, ...

Часть полей отчёта:

sleepFinishdeviceBedTimedeviceWakeupTimesleepQualitysleepEfficiencyentrySleepDurationlinBedDurationgoBedTimeleaveBedTime

Для пульса и SpO2 использовалась одинаковая вложенная структура:

int16 intervalint16 record_countuint32 start_time    # для version >= 2byte[record_count] values

Минимальный парсер этой части в Python выглядит так:

def parse_sleep_assist_info(b, pos, byte_count, version):    interval = struct.unpack_from("<h", b, pos)[0]    record_count = struct.unpack_from("<h", b, pos + 2)[0]    pos += 4    start_time = 0    if version >= 2:        start_time = struct.unpack_from("<I", b, pos)[0]        pos += 4    values = []    for _ in range(record_count):        values.append(b[pos])        pos += byte_count    return {        "start_time": start_time,        "interval": interval,        "record_count": record_count,        "values": values,    }, pos

На этом этапе весь путь распаковки и расшифровки FDS-объекта стал полностью прозрачным:

Схема расшифровки FDS

Схема расшифровки FDS

Что удалось получить

После парсинга одного ночного FDS-файла появились детальные записи, которых не было в простом JSON summary.

Проверяемый результат:

FDS content length: 43904Parsed 326 HR readings and 42 SpO2 readings from FDS.

На другой ночи:

FDS content length: 44544Parsed 348 HR readings and 44 SpO2 readings from FDS.

То есть вместо “средний пульс за ночь” получился временной ряд. Не идеальный медицинский прибор, не диагностический инструмент, но уже нормальные данные для домашнего наблюдения: когда были провалы SpO2, как менялся пульс, в какие интервалы сон был беспокойным.

Пример структуры после парсинга:

{  "report": {    "sleepFinish": true,    "deviceBedTime": "<TIMESTAMP>",    "deviceWakeupTime": "<TIMESTAMP>",    "sleepEfficiency": 92  },  "records": {    "heart_rate": [      ["<TIMESTAMP>", 76],      ["<TIMESTAMP>", 69]    ],    "spo2": [      ["<TIMESTAMP>", 95],      ["<TIMESTAMP>", 96]    ]  }}

Для статьи важно не количество байтов, а сам факт: Mi Fitness хранит детальные ночные данные в FDS, они защищены несколькими слоями, но после разборки становятся обычными временными рядами.

Самая обидная ошибка: 404 после победы

Когда сетевой RC4, STS/FDS и AES уже были понятны, оставалась неприятная ошибка: gen_download_url иногда давал ссылку, но скачивание возвращало 404 Object Not Found.

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

FDS object key строился из нескольких частей. Одна из них — suffix, закодированный из timestamp, timezone, dailyType и fileType. В коде был генератор:

def gen_data_id_key_bytes(timestamp, tz_in_15min, daily_type, file_type):    data_type_byte = (daily_type << 2) + file_type    return struct.pack("<IbB", timestamp, tz_in_15min, data_type_byte)

Проблема была в timezone. Сначала я считал, что timezone приходит в секундах, и делал:

tz_in_15min = timezone_value // 900

Но для sleep segment Xiaomi уже отдавала timezone в 15-минутных единицах. Для Москвы это было 12, а не 10800. Деление 12 // 900 превращало timezone в ноль, suffix становился другим, и ссылка указывала на объект, которого физически нет.

Финальная нормализация:

def normalize_timezone_to_15min(timezone_value: int) -> int:    if abs(timezone_value) <= 96:        return timezone_value    return timezone_value // 900

Почему граница 96? В сутках 24 часа, в каждом часе 4 интервала по 15 минут. 24 * 4 = 96. Если значение укладывается в этот диапазон, оно уже в 15-минутных единицах. Если нет, вероятно, это секунды.

Это хороший пример ошибки, которая выглядит как security problem, но является обычной ошибкой формата данных. Не всякий 404 в закрытом API означает “нет прав”. Иногда это значит “ты неправильно назвал коробку на складе”.

Превращаем исследование в сервис

Скрипт в ~/Downloads — это хорошо для исследования, но плохо для жизни. Нужен сервис, который можно перезапустить, обновить и оставить работать в режиме 24/7.

Весь исходный код проекта, конфигурационные файлы контейнеров и парсеры открыты и опубликованы на GitHub: iAlexeyRu/miband-bot.

Финальная архитектура self-hosted решения выглядит следующим образом:

Финальная Docker-архитектура

Финальная Docker-архитектура

В Docker Compose развернуты два изолированных контейнера:

services:  tracker:    container_name: miband-tracker    restart: unless-stopped    volumes:      - ./data:/opt/miband-tracker/data    env_file:      - secrets.env    environment:      - DB_PATH=/opt/miband-tracker/data/miband.db      - STATUS_PATH=/opt/miband-tracker/data/status.json      - SYNC_INTERVAL=900      - QUERY_DURATION=2  fitness-bot:    container_name: miband-fitness-bot    restart: unless-stopped    command: ["python", "-u", "fitness_bot.py"]    volumes:      - ./data:/opt/miband-tracker/data    env_file:      - secrets.env

В базе данных SQLite хранятся структурированные таблицы: steps_daily (активность), steps_detail (поминутные шаги), sleep_daily (общий сон), sleep_stages (фазы сна), heart_rate (пульс), stress(стресс), blood_oxygen (SpO2).

Синхронизатор (tracker) каждые 15 минут выполняет следующие действия:

  1. Проверяет и при необходимости обновляет сессию авторизации Xiaomi.

  2. При истечении STS-токенов делает новый защищенный exchange-запрос.

  3. Запрашивает агрегированные дневные отчеты: шаги, сон, пульс, SpO2.

  4. Вычисляет FDS-суффикс для ночного сегмента сна и запрашивает временный signed URL через gen_download_url.

  5. Скачивает FDS-объект, расшифровывает его AES/CBC ключом obj_key.

  6. Парсит бинарный слепок сна и записывает детальные посекундные ряды пульса и SpO2 в SQLite.

  7. Обновляет файл status.json для бота.

Telegram-бот (fitness-bot) выступает в качестве интерактивного и удобного интерфейса к собранным данным. Он поддерживает ограничение по Telegram User ID (доступ только владельцу и членам семьи) и управляется с помощью динамического меню на Inline-кнопках:

  • 📅 Календарь — просмотр истории активности и сна за последние 7 или 30 дней с возможностью открыть детальный разрез по любому конкретному дню.

  • 📊 Аналитика — агрегированная сводка активности (шаги, дистанция, достижение целей) и качества сна за неделю или месяц.

  • 😴 Детали сна — подробнейший отчет за последнюю ночь (время в постели, фазы глубокого/легкого/REM-сна, пульс покоя) с визуализацией трендов ЧСС и SpO2 с помощью текстовых спарклайнов.

  • ⚙️ Сервис — раздел технических функций: запуск принудительной синхронизации, проверка объема таблиц SQLite и выгрузка ZIP-архива с CSV-таблицами всех накопленных метрик.

Также поддерживаются три базовые слэш-команды быстрого доступа:

  • /start — открыть или обновить главное интерактивное меню.

  • /status — вывести технический статус базы данных и лог синхронизации.

  • /sync — принудительно запустить сбор данных из облака прямо сейчас.

Вот как выглядит отчет о качестве сна в Telegram-боте с построенным графиком ночного пульса:

Мокап Telegram-бота

Скриншот Telegram-бота

Что бы я сделал иначе

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

Первое: не стоит слишком долго пытаться “дожать” iOS MITM без jailbreak, если приложение явно использует pinning. Иногда дешевле сразу перейти на Android и APK patching.

Второе: токен из перехвата — это не финал. В современных мобильных API часто есть прикладная криптография поверх HTTPS. Если простой curl даёт 401, это не всегда значит, что токен плохой. Возможно, вы не повторили подпись, nonce, шифрование или формат тела.

Третье: декомпилированный APK — это документация, просто написанная не для вас. Названия классов вроде SmartHomeRc4ApiRC4DropCoderAESCoderFitnessDataParser часто экономят часы слепого перебора.

Четвёртое: object storage почти всегда добавляет отдельную модель доступа. Health API может только выписывать временную ссылку, а сам файл будет жить в FDS/CDN и иметь собственный ключ шифрования.

Пятое: бинарный формат не обязательно страшен. Если есть timestamp, version, bitset валидности и массивы значений, его можно восстановить маленькими шагами.

Ограничения решения и известные грабли

Как и любой проект, построенный на анализе закрытых облачных систем, наше решение имеет ряд особенностей и ограничений, о которых стоит знать перед развертыванием:

  1. Время жизни сессионных токенов: Авторизационные токены Xiaomi (serviceToken) не вечны. Периодически сессия будет протухать (обычно раз в несколько месяцев), и для восстановления синхронизации потребуется повторно получить свежие куки через mitmproxy и обновить secrets.env.

  2. Привязка к региональным IDC: Облако Xiaomi распределено по регионам (IDC). Endpoint-ы, форматы запросов и ключи могут слегка отличаться для аккаунтов с регионами EU, CN или US. Данное решение протестировано и гарантированно работает на RU-регионе.

  3. Изменчивость бинарных форматов: Формат хранения данных сна внутри FDS-объектов целиком контролируется Xiaomi. При крупном обновлении прошивки браслета или самого приложения Mi Fitness структура байтов может измениться, что потребует корректировки парсера.

  4. Немедицинский статус данных: Все собираемые браслетом метрики (пульс, фазы сна, SpO2) носят исключительно информационный характер. Данные не подлежат использованию для медицинской диагностики, а локальный мониторинг служит лишь инструментом для личного трекинга активности.

  5. Зависимость от стороннего облака: Если у Xiaomi временно упадут серверы или изменится корневой протокол шифрования, локальная синхронизация прекратит работу до внесения правок в код синхронизатора.

Итоги

В результате получилась не просто разовая выгрузка из Mi Fitness, а полноценная локальная система:

Xiaomi Cloud -> FDS -> Python sync -> SQLite -> Telegram bot

Главные технические результаты:

patched Mi Fitness без rootmitmproxy capture реальных endpoint-овRC4 request/response decryptFDS gen_download_url flowAES/CBC decrypt через obj_keyparser all-day sleep binary326+ HR readings и 42+ SpO2 readings за ночьDocker Compose сервис + Telegram bot

Понятно, что это вечная игра в кошки-мышки: в любой момент Xiaomi может выкатить обновление прошивки, перетряхнуть бинарный формат в FDS или переписать логику STS-авторизации. Но пока схема стабильно работает — локальный self-hosted стек полностью закрывает мои задачи по мониторингу без зависимости от сторонних аналитических сервисов.

Код парсеров и бота открыт, ссылка на гитхаб есть в разделе выше. Если захотите развернуть у себя или дописать поддержку других типов данных (например, спортивных тренировок) — пулл-реквесты категорически приветствуются. Вопросы и идеи жду в комментариях!

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