mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-17 18:55:37 +08:00
Add Mi Fitness sleep and workout sync support
This commit is contained in:
1 parent
6440121d67
commit
62be8dfc2b
3 files changed
+230
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in new issue
Block a user