mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +08:00
676 lines
26 KiB
Python
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
|