Add Mi Fitness sleep and workout sync support

This commit is contained in:
binglua committed 2026-06-21 11:45:53 +00:00
1 parent 6440121d67
commit 62be8dfc2b
3 files changed
+230 -4

No files matched your search

+168 -2
View File
@@ -18,6 +18,7 @@ from mi_fitness_mcp.models import (
DailyActivity,
HeartRateSample,
SleepSession,
SleepStage,
Workout,
)
@@ -233,6 +234,7 @@ class MiFitnessCloudAdapter(DataAdapter):
"daily_activity": "steps",
"heart_rate": "heart_rate",
"body_measurements": "weight",
"sleep": "sleep",
}
for data_type, key in probes.items():
try:
@@ -241,6 +243,12 @@ class MiFitnessCloudAdapter(DataAdapter):
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:
@@ -254,6 +262,57 @@ class MiFitnessCloudAdapter(DataAdapter):
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
@@ -313,17 +372,124 @@ class MiFitnessCloudAdapter(DataAdapter):
start_date: str | None = None,
end_date: str | None = None,
) -> AsyncIterator[SleepSession]:
if False:
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 False:
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,
+1 -1
View File
@@ -162,7 +162,7 @@ def main():
sync_parser = subparsers.add_parser("sync", help="Sync data from source")
sync_parser.add_argument(
"--type",
choices=["daily_activity", "body_measurements", "heart_rate"],
choices=["daily_activity", "body_measurements", "heart_rate", "sleep", "workouts"],
help="Type of data to sync",
)
sync_parser.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
+61 -1
View File
@@ -132,6 +132,34 @@ async def list_tools() -> list[Tool]:
"required": ["start_date", "end_date"],
},
),
Tool(
name="query_sleep",
description="Query sleep sessions",
inputSchema={
"type": "object",
"properties": {
"start_date": {"type": "string"},
"end_date": {"type": "string"},
"include_naps": {"type": "boolean"},
},
"required": ["start_date", "end_date"],
},
),
Tool(
name="query_workouts",
description="Query workouts",
inputSchema={
"type": "object",
"properties": {
"start_date": {"type": "string"},
"end_date": {"type": "string"},
"activity_types": {"type": "array", "items": {"type": "string"}},
"min_duration": {"type": "integer"},
"min_distance_km": {"type": "number"},
},
"required": ["start_date", "end_date"],
},
),
Tool(
name="get_data_coverage",
description="Get data coverage",
@@ -160,6 +188,10 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
result = await _handle_query_heart_rate(arguments)
elif name == "query_body_measurements":
result = await _handle_query_body_measurements(arguments)
elif name == "query_sleep":
result = await _handle_query_sleep(arguments)
elif name == "query_workouts":
result = await _handle_query_workouts(arguments)
elif name == "get_data_coverage":
result = await _handle_get_data_coverage(arguments)
else:
@@ -181,7 +213,7 @@ async def _handle_get_connection_status() -> dict:
last_sync = None
available_types = []
if db:
for data_type in ["daily_activity", "heart_rate", "body_measurements"]:
for data_type in ["daily_activity", "heart_rate", "body_measurements", "sleep", "workouts"]:
state = db.get_sync_state(data_type)
if state and state.get("last_sync_at"):
available_types.append(data_type)
@@ -304,6 +336,34 @@ async def _handle_query_body_measurements(arguments: dict) -> dict:
).model_dump()
async def _handle_query_sleep(arguments: dict) -> dict:
if not query_service:
return {"status": "error", "error": "Query service not initialized"}
sessions = query_service.get_sleep_sessions(
start_date=arguments["start_date"],
end_date=arguments["end_date"],
include_naps=arguments.get("include_naps", True),
)
return QueryResponse(
status="ok", source="cache", data={"sessions": sessions, "count": len(sessions)}
).model_dump()
async def _handle_query_workouts(arguments: dict) -> dict:
if not query_service:
return {"status": "error", "error": "Query service not initialized"}
workouts = query_service.get_workouts(
start_date=arguments["start_date"],
end_date=arguments["end_date"],
activity_types=arguments.get("activity_types"),
min_duration=arguments.get("min_duration"),
min_distance_km=arguments.get("min_distance_km"),
)
return QueryResponse(
status="ok", source="cache", data={"workouts": workouts, "count": len(workouts)}
).model_dump()
async def _handle_get_data_coverage(arguments: dict) -> dict:
if not query_service:
return {"status": "error", "error": "Query service not initialized"}