mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 03:03:49 +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,
|
DailyActivity,
|
||||||
HeartRateSample,
|
HeartRateSample,
|
||||||
SleepSession,
|
SleepSession,
|
||||||
|
SleepStage,
|
||||||
Workout,
|
Workout,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,6 +234,7 @@ class MiFitnessCloudAdapter(DataAdapter):
|
|||||||
"daily_activity": "steps",
|
"daily_activity": "steps",
|
||||||
"heart_rate": "heart_rate",
|
"heart_rate": "heart_rate",
|
||||||
"body_measurements": "weight",
|
"body_measurements": "weight",
|
||||||
|
"sleep": "sleep",
|
||||||
}
|
}
|
||||||
for data_type, key in probes.items():
|
for data_type, key in probes.items():
|
||||||
try:
|
try:
|
||||||
@@ -241,6 +243,12 @@ class MiFitnessCloudAdapter(DataAdapter):
|
|||||||
types.append(data_type)
|
types.append(data_type)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
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
|
return types
|
||||||
|
|
||||||
def _record_datetime(self, item: dict) -> datetime:
|
def _record_datetime(self, item: dict) -> datetime:
|
||||||
@@ -254,6 +262,57 @@ class MiFitnessCloudAdapter(DataAdapter):
|
|||||||
return raw
|
return raw
|
||||||
return json.loads(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:
|
def _optional_float(self, value: Any) -> float | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
@@ -313,17 +372,124 @@ class MiFitnessCloudAdapter(DataAdapter):
|
|||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> AsyncIterator[SleepSession]:
|
) -> AsyncIterator[SleepSession]:
|
||||||
if False:
|
if not self.is_connected() or not start_date or not end_date:
|
||||||
|
return
|
||||||
yield
|
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(
|
async def iter_workouts(
|
||||||
self,
|
self,
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
) -> AsyncIterator[Workout]:
|
) -> AsyncIterator[Workout]:
|
||||||
if False:
|
if not self.is_connected() or not start_date or not end_date:
|
||||||
|
return
|
||||||
yield
|
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(
|
async def iter_body_measurements(
|
||||||
self,
|
self,
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ def main():
|
|||||||
sync_parser = subparsers.add_parser("sync", help="Sync data from source")
|
sync_parser = subparsers.add_parser("sync", help="Sync data from source")
|
||||||
sync_parser.add_argument(
|
sync_parser.add_argument(
|
||||||
"--type",
|
"--type",
|
||||||
choices=["daily_activity", "body_measurements", "heart_rate"],
|
choices=["daily_activity", "body_measurements", "heart_rate", "sleep", "workouts"],
|
||||||
help="Type of data to sync",
|
help="Type of data to sync",
|
||||||
)
|
)
|
||||||
sync_parser.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
|
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"],
|
"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(
|
Tool(
|
||||||
name="get_data_coverage",
|
name="get_data_coverage",
|
||||||
description="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)
|
result = await _handle_query_heart_rate(arguments)
|
||||||
elif name == "query_body_measurements":
|
elif name == "query_body_measurements":
|
||||||
result = await _handle_query_body_measurements(arguments)
|
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":
|
elif name == "get_data_coverage":
|
||||||
result = await _handle_get_data_coverage(arguments)
|
result = await _handle_get_data_coverage(arguments)
|
||||||
else:
|
else:
|
||||||
@@ -181,7 +213,7 @@ async def _handle_get_connection_status() -> dict:
|
|||||||
last_sync = None
|
last_sync = None
|
||||||
available_types = []
|
available_types = []
|
||||||
if db:
|
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)
|
state = db.get_sync_state(data_type)
|
||||||
if state and state.get("last_sync_at"):
|
if state and state.get("last_sync_at"):
|
||||||
available_types.append(data_type)
|
available_types.append(data_type)
|
||||||
@@ -304,6 +336,34 @@ async def _handle_query_body_measurements(arguments: dict) -> dict:
|
|||||||
).model_dump()
|
).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:
|
async def _handle_get_data_coverage(arguments: dict) -> dict:
|
||||||
if not query_service:
|
if not query_service:
|
||||||
return {"status": "error", "error": "Query service not initialized"}
|
return {"status": "error", "error": "Query service not initialized"}
|
||||||
|
|||||||
Reference in new issue
Block a user