Xiaomi Scale API: From Android Emulator to Direct Cloud Calls

How I reverse engineered the Xiaomi cloud API to extract body composition data from a Mi Scale S400 -- eliminating a 6GB Android emulator with a 5MB Python cron job. Ten failed attempts, one breakthrough, and the signing algorithm that made it all work.

By Jose Nobile | 2026-04-22 | 20 min read

The Problem

I own a Xiaomi Mi Scale S400 (model yunmai.scales.ms104), a BLE body composition scale that syncs measurements to the Xiaomi Home app. Every time I step on it, the scale transmits 14 metrics over Bluetooth: weight, BMI, body fat percentage, muscle mass, water percentage, bone mass, heart rate, body age, visceral fat rating, body score, fat-free mass index (FFMI), basal metabolic rate (BMR), skeletal muscle mass, and protein percentage. The Xiaomi Home app displays all of this beautifully -- but it locks the data inside the app. There is no export button, no official API, no webhook, no CSV download. Nothing.

I needed that data in my web dashboard at josenobile.co/health/. The dashboard is a static site deployed on Cloudflare Pages, backed by a data.json file that gets rebuilt and git-pushed on every update. The challenge was clear: get 14 body composition metrics out of the Xiaomi ecosystem and into a structured format, automatically, without manual intervention. What followed was a three-month journey through Android emulators, failed API attempts, and eventually a breakthrough that reduced the entire pipeline from 6GB of RAM and 2 minutes per sync to 5MB of RAM and 10 seconds.

Phase 1: The Android Emulator Approach (Feb-Apr 2026)

With no API available, the first approach was brute force: run the Xiaomi Home app inside an Android emulator and scrape the data programmatically. I created a rooted Android Virtual Device (xiaomi_root AVD) and wrote a 1,500-line Python daemon called xiaomi_scale_daemon.py that managed the entire emulator lifecycle. The daemon used adb (Android Debug Bridge) to interact with the emulator: it would force-stop the Xiaomi Home app, pull the app's internal SQLite database, parse the cached measurement data, and export it through a chain of Google Sheets, CSV, and finally data.json for the dashboard.

The key data source was RKStorage, an SQLite database located at /data/data/com.xiaomi.smarthome/databases/RKStorage on the emulator's filesystem. This database stored JSON blobs containing all measurement records with full body composition data. The daemon would adb pull this file, parse the JSON entries, match them to scale readings, and push the results to Google Sheets via OAuth. From there, a CSV export fed into the static data.json build.

Report screenshots were captured through UI automation. The daemon would tap specific chart positions in the app, navigate to the body composition report screen, and trigger the Share button to export a screenshot. This required precise coordinate-based tapping, scrolling logic, and timing delays to wait for UI transitions.

The Cost

The emulator approach worked, but it was expensive and fragile. The Android emulator consumed 6GB of RAM and kept the CPU busy constantly. UI automation was brittle -- the emulator would crash, the app's login session would expire and require re-authentication, and the Xiaomi Home app would sometimes update its UI layout, breaking the coordinate-based tapping. Each sync cycle took about 2 minutes end-to-end.

The Bugs That Made It Worse

Phase 2: Ten Failed Attempts at the Cloud API (Apr 21, 2026)

The emulator was unsustainable. The goal became clear: call the Xiaomi cloud API directly from a Python script, get the measurement data as JSON, and eliminate the emulator entirely. What followed was a single day of ten distinct attempts, each failing in its own instructive way.

Attempt 1: Web Login via Python Requests

Tried to replicate the Xiaomi web login flow using Python requests. The login endpoint returned a notificationUrl indicating 2FA was required. The 2FA flow required a browser session with cookies, JavaScript execution, and a CAPTCHA -- impossible to complete programmatically with plain HTTP requests.

Attempt 2: Web Login via Chrome MCP Browser Automation

Used Chrome MCP (browser automation via Model Context Protocol) to drive a real browser through the Xiaomi login flow. This actually worked for authentication -- but after too many API test calls from the same session, Xiaomi's rate limiter kicked in with error code 10025. The IP was now flagged and all subsequent requests were rejected.

Attempt 3: Extract Tokens from Emulator's Encrypted Account Database

Since the emulator already had a logged-in Xiaomi account, tried to extract the authentication tokens directly from the app's account databases. Every entry was prefixed with ENCRYPTED@. Xiaomi uses per-device encryption tied to the Android device's hardware identity, making the tokens unreadable outside the emulator.

Attempt 4: MMKV login_account Binary File

Xiaomi uses MMKV (memory-mapped key-value storage by Tencent) for some account data. Found the login_account binary file on the emulator filesystem. The file was either empty or encrypted -- MMKV supports AES-128 encryption, and Xiaomi had it enabled.

Attempt 5: Android AccountManager System Databases

Android stores account credentials in the system's AccountManager database. Pulled the accounts database from the emulator -- it was empty. Xiaomi does not use Android's built-in account system at all; they implement their own account management entirely within the app.

Attempt 6: mitmproxy HTTPS Interception

Set up mitmproxy to intercept HTTPS traffic from the emulator. Installed the mitmproxy CA certificate on the emulator as a system-trusted root certificate. Result: no traffic captured. The Xiaomi Home app uses certificate pinning -- it only trusts specific certificates, rejecting the proxy's certificate entirely. The app simply refused to make any network requests through the proxy.

Attempt 7: Frida Hooks on OkHttp

Deployed frida-interception-and-unpinning to bypass certificate pinning and hook into OkHttp (the HTTP client library used by the Xiaomi app). The hooks loaded successfully and the certificate pinning was bypassed -- but during the observation window, the app did not make any API calls to the scale data endpoint. The app batches its sync requests unpredictably.

Attempt 8: Full App Data Pull (533MB)

Switched strategy entirely. Instead of trying to intercept live traffic, pulled the entire app data directory from the emulator: 533MB compressed into a tar.gz archive. Searched the extracted data offline. Found two critical pieces: xiaomi_account.xml containing the serviceToken, and OkHttp cache files containing complete request URLs with embedded ssecurity parameters (value: ujNBHtuFRZJH0WxtOSm1iQ==). This was the first real progress.

Attempt 9: Using Extracted Tokens with API Endpoints

Armed with the serviceToken and ssecurity, tried calling various Xiaomi API endpoints. Every request returned "invalid signature". The signing algorithm was wrong. Xiaomi's API doesn't use simple token-based auth -- it requires a complex cryptographic signature computed from the ssecurity, a nonce, and the request parameters. Without the correct signing algorithm, the tokens were useless.

Attempt 10: Login from a Different Server (IP Bypass)

To bypass the rate limit from Attempt 2, performed the Xiaomi web login from a different server (server3, different IP). The login succeeded and a verification code was received. But the verification context was IP-bound -- the code had to be submitted from the same IP that initiated the login. When the code was submitted from the original development machine, it was rejected.

The Breakthrough (Apr 21-22, 2026)

After ten failed attempts, I deployed a deep research agent to analyze the problem from a different angle. Instead of trying to figure out the API by trial and error, the agent searched GitHub for open-source projects that had already reverse engineered Xiaomi's cloud API. It found three critical repositories:

The breakthrough had two parts. First, the correct API endpoint. All my previous attempts used /user/get_user_device_data, which is the generic MIoT device data endpoint. The S400 scale does not store its data through the MIoT system at all. It uses a completely separate subsystem: the eco/scale system. The correct endpoint is:

/eco/common/scale/getUserDataByPage

Second, the request required a special header that was not documented anywhere: MIOT-REQUEST-MODEL: yunmai.scales.ms104. Without this header, the eco/scale endpoint would return an empty result even with valid authentication. This header tells the backend which specific scale model's data to return.

With the correct endpoint, the correct header, and the correct signing algorithm extracted from SmartScaleConnect's Go source code, I wrote fetch_scale_cloud.py. On the first run, it returned 386 measurement records in 10 seconds. Every record contained all 14 body composition metrics, timestamped and structured as JSON. Three months of emulator wrestling, solved in one script.

Technical Deep Dive: The Signing Algorithm

Xiaomi's API signing is a multi-step cryptographic process that combines SHA-256, SHA-1, and RC4 encryption. No official documentation exists for this algorithm -- it was reverse engineered from working implementations. Here is the complete signing flow in Python.

Step 1: Nonce Generation

The nonce is a 12-byte value: 8 random bytes followed by 4 bytes representing the current Unix timestamp in minutes. This ensures each request has a unique, time-bound identifier.

import os, struct, time, base64

def generate_nonce():
    millis = int(time.time()) // 60
    return base64.b64encode(
        os.urandom(8) + struct.pack(">I", millis)
    ).decode()

Step 2: Signed Nonce (SHA-256)

The signed nonce combines the ssecurity value (extracted from the login session) with the nonce using SHA-256. This binds the request to both the user's session key and the specific request timestamp.

import hashlib

def signed_nonce(ssecurity, nonce):
    hash_obj = hashlib.sha256(
        base64.b64decode(ssecurity) + base64.b64decode(nonce)
    )
    return base64.b64encode(hash_obj.digest()).decode()

Step 3: RC4 Encryption with 1024-Byte Key Skip

Parameters are encrypted using RC4, but with a critical twist: the first 1024 bytes of the RC4 keystream are discarded before encryption begins. This is a well-known hardening technique (RC4-drop) that mitigates known weaknesses in RC4's early keystream output.

from Crypto.Cipher import ARC4

def rc4_encrypt(key_b64, data):
    key = base64.b64decode(key_b64)
    cipher = ARC4.new(key)
    cipher.encrypt(b"\x00" * 1024)  # skip first 1024 bytes
    return base64.b64encode(cipher.encrypt(data.encode())).decode()

Step 4: Double Signature (SHA-1)

The signature process uses SHA-1 twice. First, a hash is computed over the plaintext parameters (before RC4 encryption) -- this produces rc4_hash__. Then, after all parameters are RC4-encrypted, a second SHA-1 hash is computed over the encrypted values -- this produces the final signature. Both hashes are included in the request.

import hashlib

def sign_request(uri, params, snonce):
    # 1. Sort and join plaintext params for rc4_hash__
    param_str = "&".join(
        f"{k}={v}" for k, v in sorted(params.items())
    )
    sign_base = f"{uri}&{snonce}&{param_str}"
    rc4_hash = hashlib.sha1(sign_base.encode()).hexdigest()

    # 2. RC4-encrypt each parameter value
    encrypted = {}
    for k, v in params.items():
        encrypted[k] = rc4_encrypt(snonce, str(v))

    # 3. Compute signature over encrypted params
    enc_str = "&".join(
        f"{k}={v}" for k, v in sorted(encrypted.items())
    )
    sig_base = f"{uri}&{snonce}&{enc_str}"
    signature = hashlib.sha1(sig_base.encode()).hexdigest()

    encrypted["rc4_hash__"] = rc4_hash
    encrypted["signature"] = signature
    encrypted["_nonce"] = snonce
    encrypted["ssecurity"] = snonce  # signed nonce, not raw
    return encrypted

Step 5: The Complete API Call

Putting it all together, here is the complete call to fetch scale data from the Xiaomi cloud API.

import requests

def fetch_scale_data(service_token, ssecurity, user_id):
    url = "https://api.io.mi.com/app/eco/common/scale/getUserDataByPage"
    nonce = generate_nonce()
    snonce = signed_nonce(ssecurity, nonce)

    params = {
        "uid": str(user_id),
        "pageNum": "1",
        "pageSize": "200",
        "startTime": "0",
    }
    signed = sign_request(
        "/eco/common/scale/getUserDataByPage",
        params, snonce
    )
    headers = {
        "Cookie": f"serviceToken={service_token}; userId={user_id}",
        "MIOT-REQUEST-MODEL": "yunmai.scales.ms104",
        "User-Agent": "Android-7.1.1-1.0.0-ONEPLUS A3010-136-"
                      f"{user_id} APP/com.xiaomi.smarthome",
    }
    resp = requests.post(url, data=signed, headers=headers)
    return resp.json()

Body Composition Field Mapping

The API returns body composition data in a nested JSON structure. Each measurement record contains a bodyData object with numeric fields that map to the 14 body composition metrics. Here is the field mapping extracted from the SmartScaleConnect source code.

FIELD_MAP = {
    "weight":         "weight",        # kg
    "bmi":            "bmi",           # index
    "fat":            "bodyFat",       # percentage
    "muscle":         "muscle",        # kg
    "water":          "water",         # percentage
    "bone":           "bone",          # kg
    "heartRate":      "heartRate",     # bpm
    "bodyAge":        "bodyAge",       # years
    "visceralFat":    "visceralFat",   # rating 1-30
    "bodyScore":      "score",         # 0-100
    "ffmi":           "ffmi",          # index
    "bmr":            "bmr",           # kcal/day
    "skeletalMuscle": "skeletalMuscle",# percentage
    "protein":        "protein",       # percentage
}

Why Previous Attempts Failed

Looking back at the ten failed attempts, there were four distinct categories of failure. Understanding these explains why this problem was so hard to solve without access to the source code of a working implementation.

Wrong Endpoint

The generic MIoT device data endpoint (/user/get_user_device_data) is the documented way to get data from Xiaomi IoT devices. But the S400 scale does not use the MIoT system for data storage. It routes through a completely separate eco/scale subsystem with its own API (/eco/common/scale/getUserDataByPage). Nothing in Xiaomi's public documentation mentions this endpoint. The only way to discover it was to find someone who had already reverse engineered it.

Wrong Signing Algorithm

Xiaomi's API uses a non-standard signing algorithm that combines SHA-256 (for the signed nonce), RC4 with a 1024-byte skip (for parameter encryption), and SHA-1 (for the double signature). Most API reverse engineering guides describe HMAC-SHA256 or simple token-based auth. Xiaomi uses neither. Without the exact algorithm -- SHA-256 for the nonce, RC4 for encryption, SHA-1 for signatures -- every request returns "invalid signature" with no further diagnostic information.

Token Confusion

Xiaomi uses at least three different serviceTokens for different service domains: xiaomihome, home.mi.com, and io.mi.com. The scale data API requires the xiaomihome service token specifically. Using a token from a different service domain results in authentication failure, even though all three tokens belong to the same Xiaomi account. The OkHttp cache files from the app data pull (Attempt 8) were critical for identifying which token corresponded to which service.

Device-Bound Encryption

Xiaomi's encrypted account storage (the ENCRYPTED@ prefix in Attempts 3-5) is tied to the Android device's hardware identity. The encryption key is derived from device-specific hardware identifiers that cannot be replicated outside the device. This made direct token extraction from the emulator's databases impossible -- the only path was through the OkHttp cache, which stored URLs in plaintext because they had already been constructed for transmission.

Phase 3: Cloud-Only Pipeline (Current)

The final pipeline is cloud_sync.py, a lightweight script that runs as a cron job every 30 minutes. It calls the Xiaomi cloud API, writes the results to CSV, exports data.json for the health dashboard, commits and pushes to git, and Cloudflare Pages deploys automatically. The entire cycle takes 10 seconds and uses 5MB of RAM.

The Android emulator is completely eliminated. No more 6GB RAM consumption, no more CPU-intensive emulator processes, no more fragile UI automation, no more screenshot OCR, no more login expiration recovery. The containerized pipeline is just a Python script, a cron entry, and a git push.

Before vs. After

MetricEmulator (Phase 1)Cloud API (Phase 3)
RAM usage6 GB5 MB
Sync time~2 minutes10 seconds
Code size1,500 lines (daemon)~200 lines (script)
DependenciesAndroid SDK, AVD, adb, uiautomator, OCRPython requests, pycryptodome
Failure modesEmulator crash, login expiry, UI change, scroll bug, uiautomator hangToken expiry (90-day refresh)
CPU impactConstant (emulator process)Near zero (10s burst every 30min)

Results

1200x less RAM

From 6GB (Android emulator) to 5MB (Python script). The server that ran the emulator now has 6GB free for other workloads.

12x faster sync

From ~2 minutes (emulator boot, app launch, UI navigation, data extraction) to 10 seconds (single API call, parse, export).

386 records recovered

The first API call returned the complete measurement history -- every body composition reading since the scale was first set up, structured as JSON with all 14 metrics.

Zero maintenance

The cloud pipeline has run unattended since deployment. No emulator crashes, no login recovery, no UI automation fixes. The only maintenance is token refresh every 90 days.

Lessons Learned

Pull the full app data and search offline. Attempts 1-7 all tried to intercept or extract data in real-time. Attempt 8 -- pulling 533MB of raw app data and searching it offline -- was the first to yield actionable results. The OkHttp cache contained complete request URLs with all authentication parameters in plaintext. When live interception fails, the answer is often sitting in the app's cached data.

OkHttp cache files are a goldmine. Android apps using OkHttp (which is most of them) cache HTTP responses on disk. These cache files contain the full request URL, including query parameters and auth tokens. Even when the app's own databases are encrypted, the HTTP cache is often stored in plaintext because the data was already transmitted over the network.

Signing algorithm documentation does not exist -- reverse engineer from working code. Xiaomi's signing algorithm is not documented anywhere in their public API documentation, developer forums, or partner documentation. The only way to get it right was to find a working implementation in an open-source project (SmartScaleConnect's Go code) and translate it to Python. When dealing with undocumented APIs, finding someone who has already solved the problem is more productive than trying to reverse engineer from scratch.

Different Xiaomi services use different API base URLs. The scale data lives on api.io.mi.com, but other Xiaomi services use us.api.io.mi.com, de.api.io.mi.com, or sg.api.io.mi.com depending on the account's region. Using the wrong base URL returns authentication errors that look identical to signing failures, adding confusion during debugging.

The AI agent found what manual research could not. I spent a full day on manual reverse engineering attempts. A deep research agent, given the problem statement and the list of failures, found the three key GitHub repositories in minutes. The agent's advantage was exhaustive search coverage -- it could scan thousands of GitHub repositories, issues, and discussions in parallel, cross-referencing terms like "yunmai," "ms104," "eco/scale," and "getUserDataByPage" that I would not have thought to search for.

Resources

Related Guides