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
- La contraseña de Xiaomi contenía un carácter
$. El comando estándaradb shell input textinterpreta$como variable de shell. La solución:adb shell input keycombination 59 11(Shift+4 = $). Cada carácter especial en la contraseña necesitaba un mapeo de keycombination. - Los códigos de verificación expiraban en 60 segundos. Para cuando el daemon detectaba el prompt de verificación, hacía OCR del código de una captura, y lo ingresaba, el código ya había expirado frecuentemente. La ventana de tiempo era mínima.
- Hacer scroll más allá del borde de una lista en Xiaomi Home navegaba fuera de la pantalla actual por completo, requiriendo que el daemon detectara el cambio de navegación y reiniciara la secuencia.
uiautomator dumpse colgaba indefinidamente en algunas pantallas, bloqueando todo el daemon. Se necesitaba un wrapper con timeout para cada llamada a dump.- Python 3.13 cambió la evaluación de truthiness de
ElementTree. Un chequeoif element:que antes evaluaba como truthy para elementos sin hijos empezó a lanzar advertencias de deprecación y a comportarse diferente. Esto rompió el parseo XML silenciosamente.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
- AlexxIT/SmartScaleConnect (Go) -- una integración de Home Assistant para básculas Xiaomi con el algoritmo de firma completo y el endpoint correcto
- Squachen/micloud (Python) -- una biblioteca cliente cloud de Xiaomi con autenticación y firma de solicitudes funcionando
- Yonsm/MiService -- otra biblioteca de servicios Xiaomi con documentación de endpoints API
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étrica | Emulador (Fase 1) | API Cloud (Fase 3) |
|---|---|---|
| Uso de RAM | 6 GB | 5 MB |
| Tiempo de sync | ~2 minutos | 10 segundos |
| Tamaño de código | 1,500 líneas (daemon) | ~200 líneas (script) |
| Dependencias | Android SDK, AVD, adb, uiautomator, OCR | Python requests, pycryptodome |
| Modos de fallo | Caída del emulador, expiración de login, cambio de UI, bug de scroll, cuelgue de uiautomator | Expiración de token (refresh cada 90 días) |
| Impacto CPU | Constante (proceso del emulador) | Casi cero (ráfaga de 10s cada 30min) |
Resultados
De 6GB (emulador Android) a 5MB (script Python). El servidor que corría el emulador ahora tiene 6GB libres para otras cargas de trabajo.
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).
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.
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.