API Xiaomi Scale: Del Emulador Android a Llamadas Directas a la Nube

Cómo hice ingeniería inversa de la API cloud de Xiaomi para extraer datos de composición corporal de una Mi Scale S400 -- eliminando un emulador Android de 6GB con un script Python de 5MB. Diez intentos fallidos, un descubrimiento clave, y el algoritmo de firma que lo hizo funcionar.

Por Jose Nobile | 2026-04-22 | 20 min de lectura

El Problema

Tengo una Xiaomi Mi Scale S400 (modelo yunmai.scales.ms104), una báscula de composición corporal BLE que sincroniza mediciones con la app Xiaomi Home. Cada vez que me peso, la báscula transmite 14 métricas por Bluetooth: peso, IMC, porcentaje de grasa corporal, masa muscular, porcentaje de agua, masa ósea, frecuencia cardíaca, edad corporal, grasa visceral, puntuación corporal, índice de masa libre de grasa (FFMI), tasa metabólica basal (TMB), masa muscular esquelética y porcentaje de proteína. La app Xiaomi Home muestra todo esto de forma hermosa -- pero bloquea los datos dentro de la app. No hay botón de exportar, no hay API oficial, no hay webhook, no hay descarga CSV. Nada.

Necesitaba esos datos en mi dashboard web en josenobile.co/health/. El dashboard es un sitio estático desplegado en Cloudflare Pages, respaldado por un archivo data.json que se reconstruye y se hace git-push en cada actualización. El desafío era claro: sacar 14 métricas de composición corporal del ecosistema Xiaomi a un formato estructurado, automáticamente, sin intervención manual. Lo que siguió fue un viaje de tres meses a través de emuladores Android, intentos fallidos de API, y eventualmente un descubrimiento que redujo todo el pipeline de 6GB de RAM y 2 minutos por sincronización a 5MB de RAM y 10 segundos.

Fase 1: El Enfoque del Emulador Android (Feb-Abr 2026)

Sin API disponible, el primer enfoque fue fuerza bruta: correr la app Xiaomi Home dentro de un emulador Android y scrapear los datos programáticamente. Creé un Android Virtual Device rooteado (xiaomi_root AVD) y escribe un daemon Python de 1,500 líneas llamado xiaomi_scale_daemon.py que gestionaba todo el ciclo de vida del emulador. El daemon usaba adb (Android Debug Bridge) para interactuar con el emulador: forzaba la detención de la app Xiaomi Home, extraía la base de datos SQLite interna de la app, parseaba los datos de mediciones cacheados, y los exportaba a través de una cadena de Google Sheets, CSV, y finalmente data.json para el dashboard.

La fuente de datos clave era RKStorage, una base de datos SQLite ubicada en /data/data/com.xiaomi.smarthome/databases/RKStorage en el sistema de archivos del emulador. Esta base de datos almacenaba blobs JSON conteniendo todos los registros de mediciones con datos completos de composición corporal. El daemon hacía adb pull de este archivo, parseaba las entradas JSON, las mapeaba a lecturas de la báscula, y enviaba los resultados a Google Sheets vía OAuth. De ahí, una exportación CSV alimentaba la construcción estática de data.json.

Las capturas de pantalla de reportes se capturaban mediante automatización de UI. El daemon tocaba posiciones específicas de gráficos en la app, navegaba a la pantalla de reporte de composición corporal, y activaba el botón Compartir para exportar una captura. Esto requería toques precisos basados en coordenadas, lógica de scroll y delays de tiempo para esperar transiciones de UI.

El Costo

El enfoque del emulador funcionaba, pero era caro y frágil. El emulador Android consumía 6GB de RAM y mantenía la CPU ocupada constantemente. La automatización de UI era quebradiza -- el emulador se caía, la sesión de login de la app expiraba y requería re-autenticación, y la app Xiaomi Home a veces actualizaba su layout de UI, rompiendo los toques basados en coordenadas. Cada ciclo de sincronización tomaba unos 2 minutos de principio a fin.

Los Bugs Que Lo Empeoraron

Fase 2: Diez Intentos Fallidos con la API Cloud (21 Abr 2026)

El emulador era insostenible. El objetivo se hizo claro: llamar a la API cloud de Xiaomi directamente desde un script Python, obtener los datos de mediciones como JSON, y eliminar el emulador por completo. Lo que siguió fue un solo día de diez intentos distintos, cada uno fallando de su propia manera instructiva.

Intento 1: Login Web vía Python Requests

Intenté replicar el flujo de login web de Xiaomi usando Python requests. El endpoint de login retornó una notificationUrl indicando que se requería 2FA. El flujo 2FA requería una sesión de navegador con cookies, ejecución de JavaScript y un CAPTCHA -- imposible de completar programáticamente con requests HTTP planos.

Intento 2: Login Web vía Automatización de Navegador Chrome MCP

Usé Chrome MCP (automatización de navegador vía Model Context Protocol) para manejar un navegador real a través del flujo de login de Xiaomi. Esto funcionó para la autenticación -- pero después de demasiadas llamadas de prueba a la API desde la misma sesión, el limitador de tasa de Xiaomi se activó con código de error 10025. La IP quedó marcada y todas las solicitudes subsiguientes fueron rechazadas.

Intento 3: Extraer Tokens de la Base de Datos Encriptada del Emulador

Como el emulador ya tenía una cuenta Xiaomi logueada, intenté extraer los tokens de autenticación directamente de las bases de datos de cuentas de la app. Cada entrada estaba prefijada con ENCRYPTED@. Xiaomi usa encriptación por dispositivo vinculada a la identidad de hardware del dispositivo Android, haciendo los tokens ilegibles fuera del emulador.

Intento 4: Archivo Binario MMKV login_account

Xiaomi usa MMKV (almacenamiento clave-valor mapeado en memoria por Tencent) para algunos datos de cuenta. Encontré el archivo binario login_account en el sistema de archivos del emulador. El archivo estaba vacío o encriptado -- MMKV soporta encriptación AES-128, y Xiaomi la tenía habilitada.

Intento 5: Bases de Datos del Sistema AccountManager de Android

Android almacena credenciales de cuentas en la base de datos AccountManager del sistema. Extraje la base de datos de cuentas del emulador -- estaba vacía. Xiaomi no usa el sistema de cuentas integrado de Android en absoluto; implementan su propia gestión de cuentas completamente dentro de la app.

Intento 6: Intercepción HTTPS con mitmproxy

Configuré mitmproxy para interceptar tráfico HTTPS del emulador. Instalé el certificado CA de mitmproxy en el emulador como certificado raíz de confianza del sistema. Resultado: ningún tráfico capturado. La app Xiaomi Home usa certificate pinning -- solo confía en certificados específicos, rechazando el certificado del proxy completamente.

Intento 7: Hooks Frida en OkHttp

Desplegé frida-interception-and-unpinning para bypassear certificate pinning y hookear en OkHttp (la biblioteca cliente HTTP usada por la app Xiaomi). Los hooks se cargaron exitosamente y el certificate pinning fue bypasseado -- pero durante la ventana de observación, la app no hizo ninguna llamada API al endpoint de datos de la báscula.

Intento 8: Extracción Completa de Datos de la App (533MB)

Cambié de estrategia completamente. En vez de intentar interceptar tráfico en vivo, extraje todo el directorio de datos de la app del emulador: 533MB comprimidos en un archivo tar.gz. Busqué en los datos extraídos offline. Encontré dos piezas críticas: xiaomi_account.xml conteniendo el serviceToken, y archivos de caché OkHttp conteniendo URLs completas de solicitudes con parámetros ssecurity embebidos. Este fue el primer progreso real.

Intento 9: Usando Tokens Extraídos con Endpoints API

Armado con el serviceToken y ssecurity, intenté llamar a varios endpoints de la API de Xiaomi. Cada solicitud retornó "invalid signature". El algoritmo de firma estaba mal. La API de Xiaomi no usa auth simple basada en tokens -- requiere una firma criptográfica compleja computada desde el ssecurity, un nonce y los parámetros de la solicitud.

Intento 10: Login desde un Servidor Diferente (Bypass de IP)

Para bypassear el límite de tasa del Intento 2, realicé el login web de Xiaomi desde un servidor diferente (server3, IP diferente). El login tuvo éxito y se recibió un código de verificación. Pero el contexto de verificación estaba vinculado a la IP -- el código tenía que enviarse desde la misma IP que inició el login.

El Descubrimiento (21-22 Abr 2026)

Después de diez intentos fallidos, desplegé un agente de investigación profunda para analizar el problema desde un ángulo diferente. En vez de intentar descifrar la API por prueba y error, el agente buscó en GitHub proyectos open-source que ya hubieran hecho ingeniería inversa de la API cloud de Xiaomi. Encontró tres repositorios críticos:

El descubrimiento tuvo dos partes. Primero, el endpoint correcto. Todos mis intentos anteriores usaban /user/get_user_device_data, que es el endpoint genérico de datos de dispositivos MIoT. La báscula S400 no almacena sus datos a través del sistema MIoT en absoluto. Usa un subsistema completamente separado: el sistema eco/scale. El endpoint correcto es:

/eco/common/scale/getUserDataByPage

Segundo, la solicitud requería un header especial no documentado en ninguna parte: MIOT-REQUEST-MODEL: yunmai.scales.ms104. Sin este header, el endpoint eco/scale retorna un resultado vacío incluso con autenticación válida.

Con el endpoint correcto, el header correcto, y el algoritmo de firma correcto extraído del código Go de SmartScaleConnect, escribe fetch_scale_cloud.py. En la primera ejecución, retornó 386 registros de mediciones en 10 segundos. Tres meses de lucha con el emulador, resueltos en un script.

Profundización Técnica: El Algoritmo de Firma

La firma de la API de Xiaomi es un proceso criptográfico multi-paso que combina SHA-256, SHA-1 y encriptación RC4. No existe documentación oficial para este algoritmo -- fue descifrado por ingeniería inversa de implementaciones funcionando. Acá está el flujo completo de firma en Python.

Paso 1: Generación de Nonce

El nonce es un valor de 12 bytes: 8 bytes aleatorios seguidos de 4 bytes representando el timestamp Unix actual en minutos. Esto asegura que cada solicitud tenga un identificador único y temporal.

import os, struct, time, base64

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

Paso 2: Nonce Firmado (SHA-256)

El nonce firmado combina el valor ssecurity (extraído de la sesión de login) con el nonce usando SHA-256. Esto vincula la solicitud tanto a la clave de sesión del usuario como al timestamp específico de la solicitud.

import hashlib

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

Paso 3: Encriptación RC4 con Salto de 1024 Bytes

Los parámetros se encriptan usando RC4, pero con un giro crítico: los primeros 1024 bytes del flujo de claves RC4 se descartan antes de comenzar la encriptación. Esta es una técnica de endurecimiento conocida (RC4-drop) que mitiga debilidades conocidas en la salida temprana del flujo de claves RC4.

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()

Paso 4: Firma Doble (SHA-1)

El proceso de firma usa SHA-1 dos veces. Primero, se computa un hash sobre los parámetros en texto plano (antes de la encriptación RC4) -- esto produce rc4_hash__. Luego, después de que todos los parámetros están encriptados con RC4, se computa un segundo hash SHA-1 sobre los valores encriptados -- esto produce la signature final. Ambos hashes se incluyen en la solicitud.

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

Paso 5: La Llamada API Completa

Poniendo todo junto, acá está la llamada completa para obtener datos de la báscula desde la API cloud de Xiaomi.

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()

Mapeo de Campos de Composición Corporal

La API retorna datos de composición corporal en una estructura JSON anidada. Cada registro de medición contiene un objeto bodyData con campos numéricos que mapean a las 14 métricas de composición corporal. Acá está el mapeo de campos extraído del código fuente de SmartScaleConnect.

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
}

Por Qué Fallaron los Intentos Anteriores

Mirando atrás los diez intentos fallidos, hubo cuatro categorías distintas de fallo. Entender estas explica por qué este problema era tan difícil de resolver sin acceso al código fuente de una implementación funcionando.

Endpoint Incorrecto

El endpoint genérico de datos de dispositivos MIoT (/user/get_user_device_data) es la forma documentada de obtener datos de dispositivos IoT de Xiaomi. Pero la báscula S400 no usa el sistema MIoT para almacenamiento de datos. Rutea a través de un subsistema eco/scale completamente separado con su propia API (/eco/common/scale/getUserDataByPage). Nada en la documentación pública de Xiaomi menciona este endpoint.

Algoritmo de Firma Incorrecto

La API de Xiaomi usa un algoritmo de firma no estándar que combina SHA-256 (para el nonce firmado), RC4 con salto de 1024 bytes (para encriptación de parámetros) y SHA-1 (para la firma doble). Sin el algoritmo exacto, cada solicitud retorna "invalid signature" sin información diagnóstica adicional.

Confusión de Tokens

Xiaomi emite un serviceToken distinto por cada service id (sid), cada uno acotado a su propio dominio: xiaomihome para home.mi.com y xiaomiio para io.mi.com. El endpoint de datos de la báscula vive en api.io.mi.com, así que requiere un token sid=xiaomiio específicamente -- un token xiaomihome autentica bien contra home.mi.com pero el endpoint de la báscula lo rechaza con 401 {"code":2}. Usar un token de un dominio de servicio diferente resulta en fallo de autenticación.

Encriptación Vinculada al Dispositivo

El almacenamiento encriptado de cuentas de Xiaomi (el prefijo ENCRYPTED@ en los Intentos 3-5) está vinculado a la identidad de hardware del dispositivo Android. La clave de encriptación se deriva de identificadores de hardware específicos del dispositivo. La única ruta era a través del caché OkHttp, que almacenaba URLs en texto plano porque ya habían sido construidas para transmisión.

Fase 3: Pipeline Solo-Nube (Actual)

El pipeline final es cloud_sync.py, un script liviano que corre como cron job cada 30 minutos. Llama a la API cloud de Xiaomi, escribe los resultados a CSV, exporta data.json para el dashboard de salud, hace commit y push a git, y Cloudflare Pages despliega automáticamente. Todo el ciclo toma 10 segundos y usa 5MB de RAM.

El emulador Android está completamente eliminado. No más consumo de 6GB de RAM, no más procesos de emulador intensivos en CPU, no más automatización frágil de UI, no más OCR de capturas, no más recuperación de login expirado. El pipeline containerizado es solo un script Python, una entrada de cron, y un git push.

Antes vs. Después

MétricaEmulador (Fase 1)API Cloud (Fase 3)
Uso de RAM6 GB5 MB
Tiempo de sync~2 minutos10 segundos
Tamaño de código1,500 líneas (daemon)~200 líneas (script)
DependenciasAndroid SDK, AVD, adb, uiautomator, OCRPython requests, pycryptodome
Modos de falloCaída del emulador, expiración de login, cambio de UI, bug de scroll, cuelgue de uiautomatorExpiración de token (refresh cada 90 días)
Impacto CPUConstante (proceso del emulador)Casi cero (ráfaga de 10s cada 30min)

Resultados

1200x menos RAM

De 6GB (emulador Android) a 5MB (script Python). El servidor que corría el emulador ahora tiene 6GB libres para otras cargas de trabajo.

12x más rápido

De ~2 minutos (arranque del emulador, lanzamiento de app, navegación UI, extracción de datos) a 10 segundos (una llamada API, parseo, exportación).

386 registros recuperados

La primera llamada API retornó el historial completo de mediciones -- cada lectura de composición corporal desde que la báscula fue configurada, estructurada como JSON con las 14 métricas.

Cero mantenimiento

El pipeline cloud ha corrido desatendido desde su despliegue. Sin caídas de emulador, sin recuperación de login, sin correcciones de automatización UI. El único mantenimiento es un refresh del token cada 7-8 semanas, cuando el serviceToken de Xiaomi expira del lado del servidor y la API empieza a devolver 401.

Lecciones Aprendidas

Extraer todos los datos de la app y buscar offline. Los Intentos 1-7 todos intentaron interceptar o extraer datos en tiempo real. El Intento 8 -- extraer 533MB de datos crudos de la app y buscarlos offline -- fue el primero en dar resultados accionables. El caché OkHttp contenía URLs completas de solicitudes con todos los parámetros de autenticación en texto plano.

Los archivos de caché OkHttp son una mina de oro. Las apps Android que usan OkHttp (la mayoría) cachean respuestas HTTP en disco. Estos archivos contienen la URL completa de la solicitud, incluyendo parámetros de consulta y tokens de auth. Incluso cuando las bases de datos propias de la app están encriptadas, el caché HTTP frecuentemente está en texto plano.

La documentación del algoritmo de firma no existe -- hay que hacer ingeniería inversa desde código funcionando. El algoritmo de firma de Xiaomi no está documentado en ninguna parte. La única forma de obtenerlo correctamente fue encontrar una implementación funcionando en un proyecto open-source (código Go de SmartScaleConnect) y traducirlo a Python.

Diferentes servicios Xiaomi usan diferentes URLs base de API. Los datos de la báscula viven en api.io.mi.com, pero otros servicios Xiaomi usan us.api.io.mi.com, de.api.io.mi.com o sg.api.io.mi.com dependiendo de la región de la cuenta.

El agente AI encontró lo que la investigación manual no pudo. Pasé un día completo en intentos manuales de ingeniería inversa. Un agente de investigación profunda, dado el planteamiento del problema y la lista de fallos, encontró los tres repositorios clave de GitHub en minutos.

Recursos

Guías Relacionadas