Disclaimer: All work was conducted for research purposes only, concerning data from my own account. This article is analytical in nature and does not call for unauthorized access to third-party systems, working session tokens, encryption keys, or ready-made tools for bypassing third-party security systems.
Wearable devices present a paradox: the band measures your heart rate, sleep, and activity, but the manufacturer doesn't provide a ready-made open API to integrate this data into third-party systems (like a home monitoring setup or a local database). The official Xiaomi Mi Fitness app shows beautiful graphs, but the data remains 'locked' within the mobile ecosystem.
The initial task was purely practical: to set up automatic collection of health data into a local SQLite database and display reports in a family Telegram bot. Since the band syncs with the app, which in turn syncs with the Xiaomi cloud, the data is guaranteed to be transmitted over the network. I needed to understand the format in which it's transmitted and how to retrieve it.
This article is a technical breakdown of the journey from analyzing network traffic and setting up trust for a custom CA to reverse-engineering Xiaomi's RC4 protocol, decrypting AES/CBC objects from FDS storage, and parsing the proprietary binary sleep format.
TL;DR (What I ended up with)
Research: The network protocol for communication between the Mi Fitness mobile app and the Xiaomi Health Cloud was reverse-engineered.
Cryptography: Implemented decryption for the RC4 transport layer (discarding the first 1024 bytes of the keystream) and AES/CBC decryption for binary data snapshots.
Format: Wrote a parser for Xiaomi FDS binary structures to extract detailed graphs for sleep, heart rate, and blood oxygen (SpO2).
Practice: The source code is open on GitHub and packaged into two Docker containers (a synchronizer with SQLite + a Telegram bot for interactive access).
Experiment Environment
For those who want to replicate or study the details, the tests were conducted under the following conditions:
Component | Value / Version |
|---|---|
Application | Xiaomi Mi Fitness (Android) v3.55.0i |
Devices | Xiaomi Smart Band 10 |
Account | Region: RU (Russian IDC) |
Audit Date | May 2026 |
Service Stack | Python 3.11, SQLite, Docker, Aiogram |
First, a Map of the Terrain
Before diving into specific endpoints, it's useful to map out the system. In our case, the interaction chain looks like this:

On paper, it's simple. The band sends data to the app. The app sends it to the cloud. The cloud stores some data in regular JSON responses and some in the FDS object storage. Our service needs to replicate a significant part of the app's behavior to get the data without a phone.
The first idea was a direct Linux sync with the band. For older Mi Bands, there are projects online using BLE, and Gadgetbridge supports many models, but the Mi Band 10 and new protocols quickly make this path expensive. You need to get an auth key, deal with BLE peculiarities, and maintain the device protocol. For home monitoring, this is too broad a front.
So, the focus shifted to the Xiaomi cloud. If the official app can already sync the band, let it help me understand the protocol once. After that, the task becomes server-side: authorization, requests, decryption, and saving.
First Dead End: iOS, Stream, and Empty CONNECTs
I started with the most obvious thing: intercepting the traffic of the official Mi Fitness app on an iPhone. For iOS, there are convenient apps like Stream: they set up a local VPN/HTTPS proxy and can export HAR files.
I started the interception, opened Mi Fitness, and synced the band. Records did appear in the HAR file. But the joy was short-lived:
CONNECT ru.hlth.io.mi.com:443 status: 0 content: { size: 0, text: "" }
There were many lines like this. It was clear the app was connecting to ru.hlth.io.mi.com, but the tunnel was empty. The proxy could see that the car had arrived at the gate, but it couldn't see what was in the trunk.
The reason is SSL pinning. In standard HTTPS, the client trusts certificate authorities from the system store. A MITM proxy adds its own certificate, the system says 'the certificate is trusted,' and the traffic can be decrypted. But a mobile app can be stricter: it knows the fingerprint of the Xiaomi server's certificate in advance and checks for that specific one.
It's less like checking a passport against a database and more like verifying a pre-memorized fingerprint. Stream shows the app its certificate, and iOS might trust it, but Mi Fitness says, 'the fingerprint is wrong,' and the connection isn't established.

On a non-jailbroken iOS, analyzing this kind of traffic is inconvenient. You can install a certificate, enable HTTPS sniffing, and add domains, but if the app uses strict certificate validation, the HAR file will still be just a collection of CONNECT.
So, I needed Android.
Android without Root: Preparing a Research Build
An important crossroads: for our purposes, the Android device doesn't necessarily need to be rooted. If you can't force the app to trust a local Certificate Authority (CA) at runtime, you can prepare a test build of the app: repackage the APK so that it accepts user certificates and allows traffic analysis in a controlled environment.
I downloaded Mi Fitness from APKMirror. Instead of a single .apk file, I got a modern .apkm bundle: inside was a base.apk and a set of split APKs for different architectures, languages, and screen densities.
unzip com.xiaomi.wearable_3.55.0i-...apkm -d mi_fitness_extracted ls mi_fitness_extracted
Inside were, among other things:
base.apk split_config.arm64_v8a.apk split_config.ru.apk split_config.xxhdpi.apk split_config.en.apk
Next came the usual Android research pipeline: apktool, network security config, signing, and installing the split APKs. But it wasn't without its adventures.
The automatic apk-mitm failed during the build:
[Fatal Error] :1107:143: The entity name must immediately follow the '&' in the entity reference. AndroidManifest.xml:1107: error: not well-formed (invalid token).
The meaning of the error is simple: the app's manifest contained the character &, which the XML parser expected to see as &. The official Xiaomi build lives with this, but the strict apktool refuses to recompile it.
I had to do it manually:
apktool d mi_fitness_extracted/base.apk -o mi_fitness_decoded
Then I added or replaced res/xml/network_security_config.xml so that the app would trust user certificates:
<?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>
In AndroidManifest.xml, the app already had a binding:
android:networkSecurityConfig="@xml/network_security_config"
After that, the APK was built:
apktool b mi_fitness_decoded -o mi_fitness_patched.apk
But Android won't install an arbitrary APK without a signature. And split APKs must be signed with the same key. If you only sign the base.apk, the installer will give an error about incompatible signatures. If you sign with the old jarsigner, Android might complain about the absence of Signature Scheme v2.
I needed apksigner and zipalign (the path below is for macOS with the Homebrew Android SDK installed; for Linux and Windows, the paths will differ, and if the utilities are in your PATH, you can just call apksigner):
# Для macOS Homebrew путь выглядит так. В Windows/Linux укажите ваш путь к Android SDK build-tools. APKSIGNER=/opt/homebrew/share/android-commandlinetools/build-tools/34.0.0/apksigner for 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
At this stage, it's important not to romanticize the process. It wasn't a pretty 'one command and it all worked' story, but a series of quite mundane errors: the wrong path to apksigner, v1 signatures instead of v2, split APKs with different signatures, trying to install a single base.apk on an ARM64 phone. But the result was what I needed: a research build of Mi Fitness successfully launched on a controlled, non-rooted Android device.
The First Real Interception
After installing the patched APK, the setup became standard:
Android phone -> Wi-Fi proxy -> mitmproxy on Mac -> Xiaomi Cloud
On the phone, I installed the mitmproxy CA via http://mitm.it, and in the Wi-Fi settings, I configured the proxy to my Mac's IP and port 8080. On the Mac, I ran mitmweb with a script that saves requests and responses for the relevant domains.
At this point, for the first time, I saw not empty CONNECT, but actual HTTPS requests:
ru.hlth.io.mi.com sts-hlth.io.mi.com ru.watch.iot.mi.com account.xiaomi.com
Among the paths were:
/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
And most importantly: working cookies appeared in the headers:
Cookie: cUserId=<REDACTED>; serviceToken=<REDACTED>; userId=<REDACTED> User-Agent: Android-16-3.55.0i-...
It might seem naively that it's all over. I have the token, I have the endpoints, I can start writing a Python client.
No.
Why a Simple curl Got a 401
The first test was as straightforward as possible: take the serviceToken, cUserId, User-Agent and repeat one of the requests using 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 '{}'
The response:
{"code":0,"message":"auth err"}
This was a major turning point. The token from the interception was live: the app itself was using it. But the Xiaomi Health API didn't accept a 'naked' JSON request. The app's actual requests had parameters:
_nonce data signature rc4_hash__ ssecurity
This means there was another application-level cryptographic layer on top of HTTPS. HTTPS protected the transport, but Xiaomi additionally encrypted the request and response bodies at its own protocol level.
To use an analogy: you've already arrived at the delivery office and shown your ID, but the box inside is locked with a separate lock.
The RC4 Layer: Searching for the Key in the APK
From this point on, the phone was no longer needed as an interactive tool. I had the APK, the encrypted request/response, the _nonce and ssecurity. This meant I had to figure out how the app itself encrypts and decrypts this data.
I decompiled the APK using jadx and started searching for strings:
rg -n "rc4_hash__|_nonce|ssecurity|security|RC4|decrypt|encrypt" jadx_mifitness
The key class was found in the Xiaomi SmartHome code:
SmartHomeRc4Api.java RC4DropCoder.java
The algorithm's logic turned out to be this:
key = sha256(base64decode(ssecurity) + base64decode(_nonce)) plaintext = rc4_drop1024(ciphertext, key)
drop1024 means that the first 1024 bytes of the generated RC4 keystream are simply discarded and not used for encryption. In cryptography, this is a classic method to protect against weak key attacks, where the key can be recovered from the initial bytes of the stream. In practice: if you don't discard the 1024 bytes, the decrypted result turns into garbage.
The first version of the script only decrypted a few requests. This was already proof that I was on the right track:
{"eco_api":"/bs/bind/devices","params":"{\"page\":1,\"pageSize\":50,\"status\":1}"}
Then I corrected the handling of base64, gzip, and the order of operations. Eventually, I was able to decrypt:
106 request payloads 58 response payloads

For the response, the working scheme looked like this:
HTTP response body -> gzip layer from mitmproxy/raw body -> base64 decode -> RC4 drop1024 with sha256(ssecurity + _nonce) -> JSON
And here came another useful lesson: an encrypted API is rarely broken with a single correct assumption. Sometimes the algorithm is already found, but the packaging order remains: gzip first or gzip later, URL-safe base64 or standard base64, where to get the nonce, whether the proxy itself unzipped the body.
JSON is There, but the Necessary Sleep Details Are Not
After cracking RC4, I finally saw normal JSON responses. They contained profiles, some statistics, and health API responses. But the detailed nightly heart rate and SpO2 measurements weren't directly in these JSONs as arrays like [{time, value}].
Some endpoints returned empty responses or 'not supported in the current IDC':
{"data_list":[],"has_more":false,"watermark":0}
{"code":-6,"message":"unsupport in current IDC","result":null}
But the endpoint /healthapp/service/gen_download_url turned out to be much more interesting. Its decrypted response contained the fields:
{ "result": { "<suffix>_<timestamp>": { "url": "<SIGNED_FDS_URL>", "obj_name": "<FDS_OBJECT_NAME>", "obj_key": "<REDACTED>" } } }
This meant that the detailed data was not in the regular API response, but as a separate object in Xiaomi FDS.
You can think of FDS here as a warehouse of boxes. The Health API doesn't give you the box itself; it issues a temporary pass: a signed URL. With this pass, you can go to the warehouse once and pick up the object. But inside the object, as it turned out, there was another layer of packaging.
The FDS File: A Long String Instead of JSON
The downloaded FDS object looked like a plain text file about 43 KB long:
nBfra1HbEwa_dzNGuWmTVNXzJ-U-kDxS...
file identified it as:
ASCII text, with very long lines, with no line terminators
It wasn't JSON. Not protobuf. Not pure gzip. Just a long base64/base64url string.
Searching the APK again provided the answer. In the code, I found FitnessFDSUploader, where the logic was almost a direct comment on this problem:
downloadFromFDS download: aes decrypt failed download objectKey is null
And then the main part became clear:
new AESCoder(objectKey).decrypt(downloadedText)
Opening AESCoder, the layer becomes clear:
AES/CBC/PKCS5Padding IV = "1234567887654321" key = Base64.decode(obj_key, 8) data = Base64.decode(downloadedText, 11)
In Python, this boiled down to this idea:
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)
I saved the result of the AES decryption to a local file pkcs7.bin (I named it after the PKCS#7 padding used). The file was about 32 KB. It was no longer ciphertext. But it wasn't JSON either.
Binary Format: A Box Without Labels
The first few bytes of the decrypted file looked like this:
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
The next byte:
0c -> 12
For Moscow (UTC+3), this is exactly 12 intervals of 15 minutes. This means the timezone in this format is stored not in seconds, but in quarter-hour units.
Next:
05 -> version 5 00 -> type marker
This already looked like a structure from the APK:
FitnessDataId FitnessDataHeader FitnessDataParser

An important thought: a binary blob isn't necessarily 'encrypted garbage.' Often, it's just a compact structure without fields or names. If you can find the code that reads it, it turns into regular records.
Through decompiled code and experiments, I was able to reconstruct the all-day sleep format:
bytes 0..3 timestamp, little-endian byte 4 timezone in 15-minute units byte 5 version byte 6 type marker bytes 7..8 data_valid bitset then report fields then assist-info arrays: HR, SpO2, ...
Some of the report fields:
sleepFinish deviceBedTime deviceWakeupTime sleepQuality sleepEfficiency entrySleepDuration linBedDuration goBedTime leaveBedTime
For heart rate and SpO2, the same nested structure was used:
int16 interval int16 record_count uint32 start_time # для version >= 2 byte[record_count] values
A minimal parser for this part in Python looks like this:
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
At this stage, the entire process of unpacking and decrypting the FDS object became completely transparent:

What I Managed to Get
After parsing one nightly FDS file, detailed records appeared that were not in the simple JSON summary.
Verifiable result:
FDS content length: 43904 Parsed 326 HR readings and 42 SpO2 readings from FDS.
On another night:
FDS content length: 44544 Parsed 348 HR readings and 44 SpO2 readings from FDS.
So, instead of an 'average heart rate for the night,' I got a time series. It's not a perfect medical device or a diagnostic tool, but it's proper data for home monitoring: when SpO2 levels dropped, how the heart rate changed, and during which intervals sleep was restless.
Example of the structure after parsing:
{ "report": { "sleepFinish": true, "deviceBedTime": "<TIMESTAMP>", "deviceWakeupTime": "<TIMESTAMP>", "sleepEfficiency": 92 }, "records": { "heart_rate": [ ["<TIMESTAMP>", 76], ["<TIMESTAMP>", 69] ], "spo2": [ ["<TIMESTAMP>", 95], ["<TIMESTAMP>", 96] ] } }
For the article, what's important is not the number of bytes, but the fact itself: Mi Fitness stores detailed nightly data in FDS, protected by several layers, but after unpacking, it becomes a regular time series.
The Most Frustrating Error: 404 After Victory
When the network RC4, STS/FDS, and AES were already understood, a nasty error remained: gen_download_url would sometimes provide a link, but the download would return a 404 Object Not Found.
At this stage, it's easy to think about authorization: the token expired, the STS is wrong, the link is invalid. But the reason turned out to be the object's name.
The FDS object key was constructed from several parts. One of them was a suffix, encoded from a timestamp, timezone, dailyType, and fileType. There was a generator in the code:
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)
The problem was with the timezone. At first, I assumed the timezone came in seconds and did this:
tz_in_15min = timezone_value // 900
But for the sleep segment, Xiaomi was already providing the timezone in 15-minute units. For Moscow, this was 12, not 10800. Dividing 12 // 900 turned the timezone into zero, the suffix became different, and the link pointed to an object that didn't physically exist.
Final normalization:
def normalize_timezone_to_15min(timezone_value: int) -> int: if abs(timezone_value) <= 96: return timezone_value return timezone_value // 900
Why the boundary 96? There are 24 hours in a day, and 4 intervals of 15 minutes in each hour. 24 * 4 = 96. If the value falls within this range, it's already in 15-minute units. If not, it's likely seconds.
This is a good example of an error that looks like a security problem but is actually a simple data format error. Not every 404 in a closed API means 'no permission.' Sometimes it means 'you named the box in the warehouse incorrectly.'
Turning Research into a Service
A script in ~/Downloads is good for research, but bad for real-world use. You need a service that can be restarted, updated, and left to run 24/7.
The entire source code of the project, container configuration files, and parsers are open and published on GitHub: iAlexeyRu/miband-bot.
The final architecture of the self-hosted solution looks like this:

Two isolated containers are deployed in 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
The SQLite database stores structured tables: steps_daily (activity), steps_detail (minute-by-minute steps), sleep_daily (total sleep), sleep_stages (sleep stages), heart_rate (heart rate), stress (stress), blood_oxygen (SpO2).
The synchronizer (tracker) performs the following actions every 15 minutes:
Checks and, if necessary, refreshes the Xiaomi authorization session.
When STS tokens expire, it makes a new secure exchange request.
Requests aggregated daily reports: steps, sleep, heart rate, SpO2.
Calculates the FDS suffix for the nightly sleep segment and requests a temporary signed URL via
gen_download_url.Downloads the FDS object and decrypts it with the AES/CBC key
obj_key.Parses the binary sleep snapshot and writes detailed second-by-second heart rate and SpO2 series to SQLite.
Updates the
status.jsonfile for the bot.
The Telegram bot (fitness-bot) acts as an interactive and convenient interface to the collected data. It supports restriction by Telegram User ID (access only for the owner and family members) and is controlled by a dynamic menu of Inline buttons:
📅 Calendar - view activity and sleep history for the last 7 or 30 days with the option to open a detailed breakdown for any specific day.
📊 Analytics - aggregated summary of activity (steps, distance, goal achievement) and sleep quality for a week or month.
😴 Sleep Details - a detailed report for the last night (time in bed, deep/light/REM sleep stages, resting heart rate) with visualization of heart rate and SpO2 trends using text-based sparklines.
⚙️ Service - a section for technical functions: forcing a synchronization, checking the size of SQLite tables, and exporting a ZIP archive with CSV tables of all accumulated metrics.
Three basic slash commands for quick access are also supported:
/start- open or refresh the main interactive menu./status- display the technical status of the database and the synchronization log./sync- force data collection from the cloud right now.
Here's what the Telegram bot report looks like:

What I Would Have Done Differently
After such work, it's easy to romanticize the journey: as if everything was clear, just one layer after another. In practice, some time was spent on false leads.
First: don't try to 'force' iOS MITM without a jailbreak for too long if the app clearly uses pinning. Sometimes it's cheaper to switch to Android and APK patching right away.
Second: a token from an interception is not the end. Modern mobile APIs often have application-level cryptography on top of HTTPS. If a simple curl gives a 401, it doesn't always mean the token is bad. You might not have replicated the signature, nonce, encryption, or body format.
Third: a decompiled APK is documentation, just not written for you. Class names like SmartHomeRc4Api, RC4DropCoder, AESCoder, FitnessDataParser often save hours of blind trial and error.
Fourth: object storage almost always adds a separate access model. The Health API might only issue a temporary link, while the file itself lives in FDS/CDN and has its own encryption key.
Fifth: a binary format isn't necessarily scary. If it has a timestamp, version, validity bitset, and arrays of values, it can be reconstructed in small steps.
Solution Limitations and Known Pitfalls
Like any project built on analyzing closed cloud systems, our solution has a number of features and limitations to be aware of before deployment:
Session Token Lifetime: Xiaomi authorization tokens (
serviceToken) are not permanent. The session will expire periodically (usually every few months), and to restore synchronization, you will need to obtain fresh cookies again via mitmproxy and updatesecrets.env.Binding to Regional IDCs: The Xiaomi cloud is distributed across regions (IDCs). Endpoints, request formats, and keys may differ slightly for accounts in the EU, CN, or US regions. This solution has been tested and is guaranteed to work in the RU region.
Volatility of Binary Formats: The format for storing sleep data within FDS objects is entirely controlled by Xiaomi. A major update to the band's firmware or the Mi Fitness app itself could change the byte structure, which would require adjusting the parser.
Non-Medical Status of Data: All metrics collected by the band (heart rate, sleep stages, SpO2) are for informational purposes only. The data is not intended for medical diagnosis, and local monitoring serves only as a tool for personal activity tracking.
Dependence on a Third-Party Cloud: If Xiaomi's servers go down temporarily or the root encryption protocol changes, local synchronization will stop working until the synchronizer code is updated.
Conclusion
The result is not just a one-time export from Mi Fitness, but a full-fledged local system:
Xiaomi Cloud -> FDS -> Python sync -> SQLite -> Telegram bot
Key technical results:
patched Mi Fitness без root mitmproxy capture реальных endpoint-ов RC4 request/response decrypt FDS gen_download_url flow AES/CBC decrypt через obj_key parser all-day sleep binary 326+ HR readings и 42+ SpO2 readings за ночь Docker Compose сервис + Telegram bot
It's clear that this is a perpetual cat-and-mouse game: at any moment, Xiaomi could roll out a firmware update, overhaul the binary format in FDS, or rewrite the STS authorization logic. But as long as the scheme works stably, the local self-hosted stack completely covers my monitoring needs without depending on third-party analytics services.
The code for the parsers and the bot is open source, and the link to the GitHub repository is in the section above. If you want to deploy it yourself or add support for other data types (like sports workouts), pull requests are very welcome. I look forward to your questions and ideas in the comments!