diff --git a/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py b/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py index 5e514b5..4d4dcb6 100644 --- a/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py +++ b/src/mi_fitness_mcp/adapters/mi_fitness_cloud.py @@ -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, diff --git a/src/mi_fitness_mcp/main.py b/src/mi_fitness_mcp/main.py index e1a43db..983c46f 100644 --- a/src/mi_fitness_mcp/main.py +++ b/src/mi_fitness_mcp/main.py @@ -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)") diff --git a/src/mi_fitness_mcp/server.py b/src/mi_fitness_mcp/server.py index d0ef28e..d8625b6 100644 --- a/src/mi_fitness_mcp/server.py +++ b/src/mi_fitness_mcp/server.py @@ -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"}