Files
mi-fitness-health/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py
T

676 lines
26 KiB
Python

import asyncio
import base64
import hashlib
import json
import os
import struct
from collections import defaultdict
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urlencode
import httpx
from mi_fitness_mcp.adapters.base import DataAdapter
from mi_fitness_mcp.models import (
AbnormalHeartBeatEvent,
BodyMeasurement,
DailyActivity,
HeartRateSample,
SleepSession,
SleepStage,
SpO2Sample,
StressSample,
Workout,
)
LOGIN_PREFIX = b"&&&START&&&"
KNOWN_REGIONS = ["ru", "cn", "de", "i2", "sg", "us"]
def _read_login_payload(text: str) -> dict:
payload = text.encode()
if not payload.startswith(LOGIN_PREFIX):
raise RuntimeError("unexpected Xiaomi login response")
return json.loads(payload[len(LOGIN_PREFIX) :].decode())
def _rc4_crypt(key: bytes, payload: bytes) -> bytes:
s = list(range(256))
j = 0
key_len = len(key)
for i in range(256):
j = (j + s[i] + key[i % key_len]) % 256
s[i], s[j] = s[j], s[i]
i = 0
j = 0
def next_byte() -> int:
nonlocal i, j
i = (i + 1) % 256
j = (j + s[i]) % 256
s[i], s[j] = s[j], s[i]
return s[(s[i] + s[j]) % 256]
for _ in range(1024):
next_byte()
output = bytearray()
for value in payload:
output.append(value ^ next_byte())
return bytes(output)
def _gen_nonce() -> bytes:
raw = bytearray(os.urandom(8))
raw.extend(struct.pack(">I", int(datetime.now().timestamp() // 60)))
return bytes(raw)
def _gen_signed_nonce(ssecurity: bytes, nonce: bytes) -> bytes:
return hashlib.sha256(ssecurity + nonce).digest()
def _gen_signature(method: str, path: str, values: dict[str, str], signed_nonce: bytes) -> str:
base = method + "&" + path + "&data=" + values["data"]
if "rc4_hash__" in values:
base += "&rc4_hash__=" + values["rc4_hash__"]
base += "&" + base64.b64encode(signed_nonce).decode()
return base64.b64encode(hashlib.sha1(base.encode()).digest()).decode()
class MiFitnessCloudAdapter(DataAdapter):
def __init__(
self, user_id: str | None = None, pass_token: str | None = None, region: str = "ru"
):
self.user_id = user_id
self.pass_token = pass_token
self.region = region
self._cookies = ""
self._ssecurity = b""
self._client: httpx.AsyncClient | None = None
self._connected = False
self._available_types: list[str] = []
async def connect(self) -> bool:
if not self.user_id or not self.pass_token:
return False
self._client = httpx.AsyncClient(timeout=30.0, follow_redirects=False)
try:
await self._login_with_token(self.user_id, self.pass_token)
self.region = await self._discover_region(self.region)
self._available_types = await self._discover_data_types()
self._connected = True
return True
except Exception:
self._connected = False
return False
def is_connected(self) -> bool:
return self._connected and self._client is not None
def get_user_id(self) -> str | None:
return self.user_id
def get_available_data_types(self) -> list[str]:
return self._available_types.copy()
async def _close_client(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def _login_with_token(self, user_id: str, pass_token: str) -> None:
if not self._client:
raise RuntimeError("client not initialized")
response = await self._client.get(
"https://account.xiaomi.com/pass/serviceLogin?_json=true&sid=miothealth",
headers={"Cookie": f"userId={user_id}; passToken={pass_token}"},
)
response.raise_for_status()
payload = _read_login_payload(response.text)
self.pass_token = payload["passToken"]
self.user_id = str(payload["userId"])
self._ssecurity = base64.b64decode(payload["ssecurity"])
redirect = await self._client.get(payload["location"])
redirect.raise_for_status()
cookie_parts = [value.split(";", 1)[0] for value in redirect.headers.get_list("set-cookie")]
self._cookies = "; ".join(cookie_parts)
async def _request(self, base_url: str, api_path: str, payload: dict) -> dict:
if not self._client:
raise RuntimeError("client not initialized")
last_error: Exception | None = None
for attempt in range(3):
try:
form = {"data": json.dumps(payload, separators=(",", ":"))}
nonce = _gen_nonce()
signed_nonce = _gen_signed_nonce(self._ssecurity, nonce)
form["rc4_hash__"] = _gen_signature("POST", api_path, form, signed_nonce)
encrypted: dict[str, str] = {}
for key, value in form.items():
encrypted[key] = base64.b64encode(
_rc4_crypt(signed_nonce, value.encode())
).decode()
encrypted["signature"] = _gen_signature("POST", api_path, encrypted, signed_nonce)
encrypted["_nonce"] = base64.b64encode(nonce).decode()
response = await self._client.post(
base_url + api_path,
headers={
"Cookie": self._cookies,
"Content-Type": "application/x-www-form-urlencoded",
},
content=urlencode(encrypted),
)
response.raise_for_status()
plaintext = _rc4_crypt(signed_nonce, base64.b64decode(response.text))
body = json.loads(plaintext)
if body.get("code") != 0:
raise RuntimeError(body.get("message", "unknown mi fitness error"))
return body.get("result", {})
except Exception as exc:
last_error = exc
if attempt == 2:
break
await asyncio.sleep(0.5 * (attempt + 1))
raise RuntimeError(f"Mi Fitness request failed: {last_error}")
async def _fetch_key(
self, key: str, start_date: str, end_date: str, region: str | None = None
) -> list[dict]:
region_name = region or self.region
base_url = (
"https://hlth.io.mi.com"
if region_name in ("", "cn")
else f"https://{region_name}.hlth.io.mi.com"
)
start_time = int(datetime.fromisoformat(start_date).replace(tzinfo=UTC).timestamp())
end_time = int(
datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp()
)
next_key = None
items: list[dict] = []
while True:
payload = {
"start_time": start_time,
"end_time": end_time,
"key": key,
}
if next_key:
payload["next_key"] = next_key
result = await self._request(base_url, "/app/v1/data/get_fitness_data_by_time", payload)
items.extend(result.get("data_list", []))
if not result.get("has_more") or not result.get("next_key"):
break
next_key = result.get("next_key")
return items
async def _discover_region(self, preferred_region: str) -> str:
candidates = [preferred_region] + [
region for region in KNOWN_REGIONS if region != preferred_region
]
for region in candidates:
for key in ("weight", "steps", "heart_rate"):
try:
result = await self._fetch_key(key, "2025-04-01", "2025-05-31", region=region)
if result:
return region
except Exception:
continue
return preferred_region
async def _discover_data_types(self) -> list[str]:
types: list[str] = []
probes = {
"daily_activity": "steps",
"heart_rate": "heart_rate",
"body_measurements": "weight",
"sleep": "sleep",
"spo2": "spo2",
"stress": "stress",
"abnormal_heart_beat": "abnormal_heart_beat",
}
for data_type, key in probes.items():
try:
items = await self._fetch_key(key, "2025-04-01", "2025-05-31")
if items:
types.append(data_type)
except Exception:
continue
try:
items = await self._fetch_sport_records_by_time("2025-04-01", "2025-05-31")
if items:
types.append("workouts")
except Exception:
pass
return types
def _record_datetime(self, item: dict) -> datetime:
timestamp = int(item.get("time", 0))
zone_offset = int(item.get("zone_offset", 0))
return datetime.fromtimestamp(timestamp + zone_offset, tz=UTC).replace(tzinfo=None)
def _parse_value(self, item: dict) -> dict[str, Any]:
raw = item.get("value", "{}")
if isinstance(raw, dict):
return raw
return json.loads(raw)
def _timestamp_to_datetime(self, timestamp: Any, zone_offset: int = 0) -> datetime:
return datetime.fromtimestamp(int(timestamp) + int(zone_offset), tz=UTC).replace(tzinfo=None)
async def _fetch_sport_records_by_time(
self, start_date: str, end_date: str, region: str | None = None
) -> list[dict]:
region_name = region or self.region
base_url = (
"https://hlth.io.mi.com"
if region_name in ("", "cn")
else f"https://{region_name}.hlth.io.mi.com"
)
start_time = int(datetime.fromisoformat(start_date).replace(tzinfo=UTC).timestamp())
end_time = int(
datetime.fromisoformat(end_date + "T23:59:59").replace(tzinfo=UTC).timestamp()
)
next_key = None
items: list[dict] = []
while True:
payload: dict[str, Any] = {
"start_time": start_time,
"end_time": end_time,
"limit": 50,
}
if next_key:
payload["next_key"] = next_key
result = await self._request(
base_url, "/app/v1/data/get_sport_records_by_time", payload
)
items.extend(result.get("sport_records", []))
if not result.get("has_more") or not result.get("next_key"):
break
next_key = result.get("next_key")
return items
def _sleep_stage_name(self, state: Any) -> str:
mapping = {
1: "deep",
2: "light",
3: "light",
4: "awake",
5: "rem",
}
try:
return mapping.get(int(state), "light")
except Exception:
return "light"
def _optional_float(self, value: Any) -> float | None:
if value is None:
return None
parsed = float(value)
return None if parsed == 0 else parsed
def _optional_int(self, value: Any) -> int | None:
if value is None:
return None
parsed = int(float(value))
return None if parsed == 0 else parsed
async def iter_daily_activity(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[DailyActivity]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("steps", start_date, end_date)
daily: dict[str, dict[str, float]] = defaultdict(
lambda: {"steps": 0, "distance_m": 0.0, "active_kcal": 0.0}
)
for item in records:
payload = self._parse_value(item)
date_str = self._record_datetime(item).strftime("%Y-%m-%d")
daily[date_str]["steps"] += int(payload.get("steps", 0))
daily[date_str]["distance_m"] += float(payload.get("distance", 0))
daily[date_str]["active_kcal"] += float(payload.get("calories", 0))
calorie_records = await self._fetch_key("calories", start_date, end_date)
calorie_totals: dict[str, float] = defaultdict(float)
for item in calorie_records:
payload = self._parse_value(item)
date_str = self._record_datetime(item).strftime("%Y-%m-%d")
calorie_totals[date_str] += float(payload.get("calories", 0))
for date_str, total in calorie_totals.items():
daily[date_str]["active_kcal"] = total
for date_str, values in sorted(daily.items()):
yield DailyActivity(
id=f"mi_fitness_activity_{date_str}",
provider="mi_fitness",
source_type="cloud_session",
user_id=self.user_id or "unknown",
date=date_str,
steps=int(values["steps"]),
distance_m=values["distance_m"],
active_kcal=values["active_kcal"],
)
async def iter_sleep_sessions(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[SleepSession]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("sleep", start_date, end_date)
for item in records:
payload = self._parse_value(item)
zone_offset = int(item.get("zone_offset", 0) or 0)
sleep_start = (
payload.get("bedtime")
or payload.get("device_bedtime")
or payload.get("bed_timestamp")
)
sleep_end = (
payload.get("wake_up_time")
or payload.get("device_wake_up_time")
or payload.get("out_bed_timestamp")
or item.get("time")
)
if not sleep_start or not sleep_end:
continue
start_at = self._timestamp_to_datetime(sleep_start, zone_offset)
end_at = self._timestamp_to_datetime(sleep_end, zone_offset)
duration_minutes = int(
payload.get("duration")
or max(0, (int(sleep_end) - int(sleep_start)) // 60)
)
awake_minutes = int(
payload.get("awake_duration")
or payload.get("sleep_awake_duration")
or 0
)
asleep_minutes = max(0, duration_minutes - awake_minutes)
stages: list[SleepStage] = []
for segment in payload.get("items", []) or []:
try:
seg_start = int(segment.get("start_time", 0))
seg_end = int(segment.get("end_time", 0))
minutes = max(0, (seg_end - seg_start) // 60)
if minutes:
stages.append(
SleepStage(stage=self._sleep_stage_name(segment.get("state")), minutes=minutes)
)
except Exception:
continue
sleep_id = f"{item.get('sid', self.user_id)}_{item.get('time', int(sleep_end))}"
yield SleepSession(
id=f"mi_fitness_sleep_{sleep_id}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
sleep_id=sleep_id,
start_at=start_at,
end_at=end_at,
duration_minutes=duration_minutes,
time_asleep_minutes=asleep_minutes,
time_awake_minutes=awake_minutes,
sleep_score=self._optional_int(payload.get("score") or payload.get("sleep_score")),
is_nap=bool(payload.get("is_nap", False)),
stages=stages,
)
async def iter_workouts(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[Workout]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_sport_records_by_time(start_date, end_date)
for item in records:
payload = self._parse_value(item)
zone_offset = int(item.get("zone_offset", 0) or 0)
start_ts = payload.get("start_time") or item.get("time")
end_ts = payload.get("end_time")
duration_seconds = int(payload.get("duration", 0) or 0)
if not end_ts and start_ts:
end_ts = int(start_ts) + duration_seconds
if not start_ts or not end_ts:
continue
start_at = self._timestamp_to_datetime(start_ts, zone_offset)
end_at = self._timestamp_to_datetime(end_ts, zone_offset)
duration_minutes = max(0, int(duration_seconds // 60))
if duration_minutes == 0:
duration_minutes = max(0, (int(end_ts) - int(start_ts)) // 60)
workout_id = f"{item.get('sid', self.user_id)}_{item.get('key', 'workout')}_{item.get('time', start_ts)}"
yield Workout(
id=f"mi_fitness_workout_{workout_id}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
workout_id=workout_id,
activity_type=str(item.get("category") or item.get("key") or payload.get("sport_type") or "workout"),
start_at=start_at,
end_at=end_at,
duration_minutes=duration_minutes,
distance_m=self._optional_float(payload.get("distance")),
calories_kcal=self._optional_float(payload.get("calories") or payload.get("total_cal")),
avg_heart_rate_bpm=self._optional_int(payload.get("avg_hrm")),
max_heart_rate_bpm=self._optional_int(payload.get("max_hrm")),
avg_pace_sec_per_km=self._optional_float(payload.get("avg_pace")),
max_pace_sec_per_km=self._optional_float(payload.get("max_pace")),
total_steps=self._optional_int(payload.get("steps") or payload.get("total_steps")),
)
async def iter_body_measurements(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[BodyMeasurement]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("weight", start_date, end_date)
for item in records:
payload = self._parse_value(item)
measured_at = self._record_datetime(item)
yield BodyMeasurement(
id=f"mi_fitness_weight_{int(item.get('time', 0))}",
provider="mi_fitness",
source_type="cloud_session",
user_id=self.user_id or "unknown",
timestamp=measured_at,
weight_kg=float(payload.get("weight", 0)),
bmi=float(payload["bmi"]) if payload.get("bmi") is not None else None,
body_fat_pct=self._optional_float(payload.get("body_fat_rate")),
muscle_mass_kg=self._optional_float(payload.get("muscle_rate")),
water_pct=self._optional_float(payload.get("moisture_rate")),
bone_mass_kg=self._optional_float(payload.get("bone_mass")),
visceral_fat_score=self._optional_int(payload.get("visceral_fat")),
basal_metabolism_kcal=self._optional_int(payload.get("basal_metabolism")),
metabolic_age=self._optional_int(payload.get("body_age")),
)
async def iter_heart_rate(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[HeartRateSample]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("heart_rate", start_date, end_date)
for item in records:
payload = self._parse_value(item)
sample_type = "passive" if int(payload.get("type", 0)) == 0 else "active"
yield HeartRateSample(
id=f"mi_fitness_hr_{int(item.get('time', 0))}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
timestamp=self._record_datetime(item),
bpm=int(payload.get("bpm", 0)),
sample_type=sample_type,
)
resting_records = await self._fetch_key("resting_heart_rate", start_date, end_date)
for item in resting_records:
payload = self._parse_value(item)
timestamp = payload.get("date_time") or item.get("time")
yield HeartRateSample(
id=f"mi_fitness_resting_hr_{int(timestamp or item.get('time', 0))}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
timestamp=self._timestamp_to_datetime(timestamp or item.get("time"), int(item.get("zone_offset", 0) or 0)),
bpm=int(payload.get("bpm", 0)),
sample_type="resting",
)
def _stress_level(self, score: int) -> str:
if score < 30:
return "low"
if score < 60:
return "medium"
return "high"
async def iter_spo2(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[SpO2Sample]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("spo2", start_date, end_date)
for item in records:
payload = self._parse_value(item)
timestamp = payload.get("time") or item.get("time")
spo2 = payload.get("spo2") or payload.get("value")
if timestamp is None or spo2 is None:
continue
yield SpO2Sample(
id=f"mi_fitness_spo2_{int(timestamp)}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
timestamp=self._timestamp_to_datetime(timestamp, int(item.get("zone_offset", 0) or 0)),
spo2_pct=int(float(spo2)),
)
async def iter_stress(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[StressSample]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("stress", start_date, end_date)
for item in records:
payload = self._parse_value(item)
timestamp = payload.get("time") or item.get("time")
stress = payload.get("stress") or payload.get("score") or payload.get("value")
if timestamp is None or stress is None:
continue
score = int(float(stress))
yield StressSample(
id=f"mi_fitness_stress_{int(timestamp)}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
timestamp=self._timestamp_to_datetime(timestamp, int(item.get("zone_offset", 0) or 0)),
stress_score=score,
level=self._stress_level(score),
)
async def iter_abnormal_heart_beat(
self,
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[AbnormalHeartBeatEvent]:
if not self.is_connected() or not start_date or not end_date:
return
yield
records = await self._fetch_key("abnormal_heart_beat", start_date, end_date)
for item in records:
payload = self._parse_value(item)
zone_offset = int(item.get("zone_offset", 0) or 0)
start_ts = payload.get("start_time") or item.get("time")
end_ts = payload.get("end_time") or start_ts
if start_ts is None:
continue
start_at = self._timestamp_to_datetime(start_ts, zone_offset)
end_at = self._timestamp_to_datetime(end_ts, zone_offset)
duration_seconds = max(0, int(end_ts) - int(start_ts))
event_id = f"{item.get('sid', self.user_id)}_{int(start_ts)}"
yield AbnormalHeartBeatEvent(
id=f"mi_fitness_abnormal_hr_{event_id}",
provider="mi_fitness",
source_type="cloud_session",
source_record_id=str(item.get("time", "")) or None,
user_id=self.user_id or "unknown",
timezone=item.get("zone_name") or "UTC",
collected_at=self._record_datetime(item),
event_id=event_id,
start_at=start_at,
end_at=end_at,
duration_seconds=duration_seconds,
)
async def close(self) -> None:
await self._close_client()
self._connected = False