mirror of
https://github.com/This-weiyuqing/mi-fitness-health.git
synced 2026-10-18 11:13:48 +08:00
feat: prepare mi-fitness-mcp for first public release
Package the standalone Mi Fitness MCP with clearer public docs, release metadata, CI, and safer local repository hygiene so it is ready to publish without secrets or work identity leakage.
This commit is contained in:
1 parent
6771fc841c
commit
df914ea56a
23 files changed
+754
-426
No files matched your search
@@ -1,223 +0,0 @@
|
||||
"""Background sync scheduler for Mi Fitness MCP."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable
|
||||
|
||||
from mi_fitness_mcp.adapters.base import DataAdapter
|
||||
from mi_fitness_mcp.storage import Database
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncScheduler:
|
||||
"""Scheduler for automatic background synchronization."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: DataAdapter,
|
||||
db: Database,
|
||||
sync_interval_minutes: int = 60,
|
||||
):
|
||||
"""Initialize sync scheduler.
|
||||
|
||||
Args:
|
||||
adapter: Data source adapter
|
||||
db: Database instance
|
||||
sync_interval_minutes: How often to sync (default: 1 hour)
|
||||
"""
|
||||
self.adapter = adapter
|
||||
self.db = db
|
||||
self.sync_interval = timedelta(minutes=sync_interval_minutes)
|
||||
self._running = False
|
||||
self._task: asyncio.Task | None = None
|
||||
self._last_sync: datetime | None = None
|
||||
self._sync_callback: Callable | None = None
|
||||
|
||||
def set_sync_callback(self, callback: Callable) -> None:
|
||||
"""Set callback to be called after each sync.
|
||||
|
||||
Args:
|
||||
callback: Function to call with sync results
|
||||
"""
|
||||
self._sync_callback = callback
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the sync scheduler."""
|
||||
if self._running:
|
||||
logger.warning("Scheduler already running")
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._run_scheduler())
|
||||
logger.info(f"Sync scheduler started (interval: {self.sync_interval})")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the sync scheduler."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
self._running = False
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Sync scheduler stopped")
|
||||
|
||||
async def _run_scheduler(self) -> None:
|
||||
"""Main scheduler loop."""
|
||||
from mi_fitness_mcp.services.sync_service import SyncService
|
||||
|
||||
sync_service = SyncService(self.adapter, self.db)
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Check if it's time to sync
|
||||
if self._should_sync():
|
||||
await self._perform_sync(sync_service)
|
||||
|
||||
# Wait for next check
|
||||
await asyncio.sleep(60) # Check every minute
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync scheduler: {e}")
|
||||
await asyncio.sleep(60) # Wait before retrying
|
||||
|
||||
def _should_sync(self) -> bool:
|
||||
"""Check if sync should be performed."""
|
||||
if not self._last_sync:
|
||||
return True
|
||||
|
||||
time_since_last = datetime.utcnow() - self._last_sync
|
||||
return time_since_last >= self.sync_interval
|
||||
|
||||
async def _perform_sync(self, sync_service) -> None:
|
||||
"""Perform synchronization of all data types."""
|
||||
logger.info("Starting automatic sync")
|
||||
|
||||
results = {}
|
||||
data_types = self.adapter.get_available_data_types()
|
||||
|
||||
for data_type in data_types:
|
||||
try:
|
||||
result = sync_service.sync_data_type(data_type)
|
||||
results[data_type] = result
|
||||
logger.info(
|
||||
f"Synced {data_type}: {result['added']} added, "
|
||||
f"{result['updated']} updated"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync {data_type}: {e}")
|
||||
results[data_type] = {"error": str(e)}
|
||||
|
||||
self._last_sync = datetime.utcnow()
|
||||
|
||||
# Call callback if set
|
||||
if self._sync_callback:
|
||||
try:
|
||||
self._sync_callback(results)
|
||||
except Exception as e:
|
||||
logger.error(f"Sync callback failed: {e}")
|
||||
|
||||
logger.info("Automatic sync completed")
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get scheduler status."""
|
||||
return {
|
||||
"running": self._running,
|
||||
"last_sync": self._last_sync.isoformat() if self._last_sync else None,
|
||||
"sync_interval_minutes": self.sync_interval.total_seconds() / 60,
|
||||
"next_sync": (
|
||||
(self._last_sync + self.sync_interval).isoformat()
|
||||
if self._last_sync
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AutoSyncManager:
|
||||
"""Manager for automatic synchronization configuration."""
|
||||
|
||||
def __init__(self, config_path: str = "config/autosync.json"):
|
||||
"""Initialize auto-sync manager.
|
||||
|
||||
Args:
|
||||
config_path: Path to autosync configuration file
|
||||
"""
|
||||
self.config_path = config_path
|
||||
self._scheduler: SyncScheduler | None = None
|
||||
|
||||
def load_config(self) -> dict:
|
||||
"""Load auto-sync configuration."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(self.config_path)
|
||||
if not path.exists():
|
||||
return {
|
||||
"enabled": False,
|
||||
"sync_interval_minutes": 60,
|
||||
"data_types": ["daily_activity", "sleep", "workouts"],
|
||||
}
|
||||
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_config(self, config: dict) -> None:
|
||||
"""Save auto-sync configuration."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(self.config_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(path, "w") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
async def start_scheduler(
|
||||
self,
|
||||
adapter: DataAdapter,
|
||||
db: Database,
|
||||
) -> SyncScheduler | None:
|
||||
"""Start scheduler with loaded configuration.
|
||||
|
||||
Args:
|
||||
adapter: Data source adapter
|
||||
db: Database instance
|
||||
|
||||
Returns:
|
||||
SyncScheduler instance or None if not enabled
|
||||
"""
|
||||
config = self.load_config()
|
||||
|
||||
if not config.get("enabled", False):
|
||||
logger.info("Auto-sync is disabled")
|
||||
return None
|
||||
|
||||
interval = config.get("sync_interval_minutes", 60)
|
||||
|
||||
self._scheduler = SyncScheduler(
|
||||
adapter=adapter,
|
||||
db=db,
|
||||
sync_interval_minutes=interval,
|
||||
)
|
||||
|
||||
await self._scheduler.start()
|
||||
return self._scheduler
|
||||
|
||||
async def stop_scheduler(self) -> None:
|
||||
"""Stop the running scheduler."""
|
||||
if self._scheduler:
|
||||
await self._scheduler.stop()
|
||||
self._scheduler = None
|
||||
|
||||
def get_scheduler_status(self) -> dict | None:
|
||||
"""Get current scheduler status."""
|
||||
if not self._scheduler:
|
||||
return None
|
||||
return self._scheduler.get_status()
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Query service for retrieving data from database."""
|
||||
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -11,7 +12,7 @@ class QueryService:
|
||||
|
||||
def __init__(self, db: Database, user_id: str):
|
||||
"""Initialize query service.
|
||||
|
||||
|
||||
Args:
|
||||
db: Database instance
|
||||
user_id: User identifier
|
||||
@@ -22,7 +23,7 @@ class QueryService:
|
||||
def get_daily_summaries(self, start_date: str, end_date: str) -> list[dict[str, Any]]:
|
||||
"""Get daily activity summaries for date range."""
|
||||
records = self.db.query_daily_activity(self.user_id, start_date, end_date)
|
||||
|
||||
|
||||
# Group by date and aggregate
|
||||
summaries = {}
|
||||
for record in records:
|
||||
@@ -37,7 +38,7 @@ class QueryService:
|
||||
"floors": 0,
|
||||
"active_minutes": 0,
|
||||
}
|
||||
|
||||
|
||||
summaries[date]["steps"] += record.get("steps", 0)
|
||||
summaries[date]["distance_m"] += record.get("distance_m", 0)
|
||||
summaries[date]["active_kcal"] += record.get("active_kcal", 0)
|
||||
@@ -47,7 +48,7 @@ class QueryService:
|
||||
summaries[date]["floors"] += record["floors"]
|
||||
if record.get("active_minutes"):
|
||||
summaries[date]["active_minutes"] += record["active_minutes"]
|
||||
|
||||
|
||||
return list(summaries.values())
|
||||
|
||||
def get_metric_series(
|
||||
@@ -60,22 +61,24 @@ class QueryService:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get time series for a metric."""
|
||||
summaries = self.get_daily_summaries(start_date, end_date)
|
||||
|
||||
|
||||
series = []
|
||||
for summary in summaries:
|
||||
value = summary.get(metric)
|
||||
if value is not None:
|
||||
series.append({
|
||||
"date": summary["date"],
|
||||
"value": value,
|
||||
})
|
||||
|
||||
series.append(
|
||||
{
|
||||
"date": summary["date"],
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
# Apply aggregation if needed
|
||||
if granularity == "week":
|
||||
series = self._aggregate_by_week(series, aggregation)
|
||||
elif granularity == "month":
|
||||
series = self._aggregate_by_month(series, aggregation)
|
||||
|
||||
|
||||
return series
|
||||
|
||||
def _aggregate_by_week(
|
||||
@@ -85,17 +88,17 @@ class QueryService:
|
||||
) -> list[dict]:
|
||||
"""Aggregate daily series by week."""
|
||||
weeks = {}
|
||||
|
||||
|
||||
for item in series:
|
||||
date = datetime.strptime(item["date"], "%Y-%m-%d")
|
||||
# Get week start (Monday)
|
||||
week_start = date - timedelta(days=date.weekday())
|
||||
week_key = week_start.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
if week_key not in weeks:
|
||||
weeks[week_key] = []
|
||||
weeks[week_key].append(item["value"])
|
||||
|
||||
|
||||
result = []
|
||||
for week_key, values in sorted(weeks.items()):
|
||||
if aggregation == "sum":
|
||||
@@ -108,9 +111,9 @@ class QueryService:
|
||||
value = max(values)
|
||||
else:
|
||||
value = sum(values)
|
||||
|
||||
|
||||
result.append({"date": week_key, "value": value})
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def _aggregate_by_month(
|
||||
@@ -120,15 +123,15 @@ class QueryService:
|
||||
) -> list[dict]:
|
||||
"""Aggregate daily series by month."""
|
||||
months = {}
|
||||
|
||||
|
||||
for item in series:
|
||||
date = datetime.strptime(item["date"], "%Y-%m-%d")
|
||||
month_key = date.strftime("%Y-%m")
|
||||
|
||||
|
||||
if month_key not in months:
|
||||
months[month_key] = []
|
||||
months[month_key].append(item["value"])
|
||||
|
||||
|
||||
result = []
|
||||
for month_key, values in sorted(months.items()):
|
||||
if aggregation == "sum":
|
||||
@@ -141,9 +144,9 @@ class QueryService:
|
||||
value = max(values)
|
||||
else:
|
||||
value = sum(values)
|
||||
|
||||
|
||||
result.append({"date": month_key + "-01", "value": value})
|
||||
|
||||
|
||||
return result
|
||||
|
||||
def get_sleep_sessions(
|
||||
@@ -154,13 +157,13 @@ class QueryService:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get sleep sessions for date range."""
|
||||
records = self.db.query_sleep_sessions(self.user_id, start_date, end_date)
|
||||
|
||||
|
||||
sessions = []
|
||||
for record in records:
|
||||
# Skip naps if not included
|
||||
if not include_naps and record.get("is_nap"):
|
||||
continue
|
||||
|
||||
|
||||
session = {
|
||||
"sleep_id": record["sleep_id"],
|
||||
"start_at": record["start_at"],
|
||||
@@ -171,17 +174,16 @@ class QueryService:
|
||||
"sleep_score": record.get("sleep_score"),
|
||||
"is_nap": record.get("is_nap", False),
|
||||
}
|
||||
|
||||
|
||||
# Parse stages if available
|
||||
if record.get("stages"):
|
||||
import json
|
||||
try:
|
||||
|
||||
with suppress(json.JSONDecodeError):
|
||||
session["stages"] = json.loads(record["stages"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
sessions.append(session)
|
||||
|
||||
|
||||
return sessions
|
||||
|
||||
def get_workouts(
|
||||
@@ -194,37 +196,40 @@ class QueryService:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get workouts for date range with optional filters."""
|
||||
records = self.db.query_workouts(self.user_id, start_date, end_date)
|
||||
|
||||
|
||||
workouts = []
|
||||
for record in records:
|
||||
# Apply filters
|
||||
if activity_types:
|
||||
if record["activity_type"].lower() not in [t.lower() for t in activity_types]:
|
||||
continue
|
||||
|
||||
if activity_types and record["activity_type"].lower() not in [
|
||||
t.lower() for t in activity_types
|
||||
]:
|
||||
continue
|
||||
|
||||
if min_duration and record.get("duration_minutes", 0) < min_duration:
|
||||
continue
|
||||
|
||||
|
||||
if min_distance_km:
|
||||
distance_km = (record.get("distance_m") or 0) / 1000
|
||||
if distance_km < min_distance_km:
|
||||
continue
|
||||
|
||||
workouts.append({
|
||||
"workout_id": record["workout_id"],
|
||||
"activity_type": record["activity_type"],
|
||||
"start_at": record["start_at"],
|
||||
"end_at": record["end_at"],
|
||||
"duration_minutes": record["duration_minutes"],
|
||||
"distance_m": record.get("distance_m"),
|
||||
"calories_kcal": record.get("calories_kcal"),
|
||||
"avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"),
|
||||
"max_heart_rate_bpm": record.get("max_heart_rate_bpm"),
|
||||
"avg_pace_sec_per_km": record.get("avg_pace_sec_per_km"),
|
||||
"max_pace_sec_per_km": record.get("max_pace_sec_per_km"),
|
||||
"total_steps": record.get("total_steps"),
|
||||
})
|
||||
|
||||
|
||||
workouts.append(
|
||||
{
|
||||
"workout_id": record["workout_id"],
|
||||
"activity_type": record["activity_type"],
|
||||
"start_at": record["start_at"],
|
||||
"end_at": record["end_at"],
|
||||
"duration_minutes": record["duration_minutes"],
|
||||
"distance_m": record.get("distance_m"),
|
||||
"calories_kcal": record.get("calories_kcal"),
|
||||
"avg_heart_rate_bpm": record.get("avg_heart_rate_bpm"),
|
||||
"max_heart_rate_bpm": record.get("max_heart_rate_bpm"),
|
||||
"avg_pace_sec_per_km": record.get("avg_pace_sec_per_km"),
|
||||
"max_pace_sec_per_km": record.get("max_pace_sec_per_km"),
|
||||
"total_steps": record.get("total_steps"),
|
||||
}
|
||||
)
|
||||
|
||||
return workouts
|
||||
|
||||
def get_body_measurements(
|
||||
@@ -235,25 +240,30 @@ class QueryService:
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get body measurements for date range."""
|
||||
records = self.db.query_body_measurements(self.user_id, start_date, end_date)
|
||||
|
||||
|
||||
measurements = []
|
||||
for record in records:
|
||||
measurement = {
|
||||
"timestamp": record["timestamp"],
|
||||
"weight_kg": record["weight_kg"],
|
||||
}
|
||||
|
||||
|
||||
# Add optional metrics
|
||||
optional_fields = [
|
||||
"bmi", "body_fat_pct", "muscle_mass_kg", "water_pct",
|
||||
"bone_mass_kg", "visceral_fat_score", "basal_metabolism_kcal",
|
||||
"bmi",
|
||||
"body_fat_pct",
|
||||
"muscle_mass_kg",
|
||||
"water_pct",
|
||||
"bone_mass_kg",
|
||||
"visceral_fat_score",
|
||||
"basal_metabolism_kcal",
|
||||
"metabolic_age",
|
||||
]
|
||||
|
||||
|
||||
for field in optional_fields:
|
||||
if record.get(field) is not None:
|
||||
measurement[field] = record[field]
|
||||
|
||||
|
||||
# Filter metrics if specified
|
||||
if metrics:
|
||||
filtered = {"timestamp": measurement["timestamp"]}
|
||||
@@ -261,7 +271,7 @@ class QueryService:
|
||||
if metric in measurement:
|
||||
filtered[metric] = measurement[metric]
|
||||
measurement = filtered
|
||||
|
||||
|
||||
measurements.append(measurement)
|
||||
|
||||
return measurements
|
||||
@@ -294,8 +304,8 @@ class QueryService:
|
||||
def get_data_coverage(self, data_types: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""Get data coverage information."""
|
||||
coverage = self.db.get_data_coverage(self.user_id)
|
||||
|
||||
|
||||
if data_types:
|
||||
coverage = [c for c in coverage if c["data_type"] in data_types]
|
||||
|
||||
|
||||
return coverage
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from mi_fitness_mcp.adapters.base import DataAdapter
|
||||
@@ -17,7 +17,7 @@ class SyncService:
|
||||
|
||||
def __init__(self, adapter: DataAdapter, db: Database):
|
||||
"""Initialize sync service.
|
||||
|
||||
|
||||
Args:
|
||||
adapter: Data source adapter
|
||||
db: Database instance
|
||||
@@ -42,13 +42,13 @@ class SyncService:
|
||||
force_full: bool = False,
|
||||
) -> dict:
|
||||
"""Synchronize a specific data type.
|
||||
|
||||
|
||||
Args:
|
||||
data_type: Type of data to sync (daily_activity, sleep, workouts, body_measurements)
|
||||
start_date: Start date (YYYY-MM-DD), defaults to 30 days ago
|
||||
end_date: End date (YYYY-MM-DD), defaults to today
|
||||
force_full: Force full sync ignoring last sync state
|
||||
|
||||
|
||||
Returns:
|
||||
Dict with sync statistics
|
||||
"""
|
||||
@@ -65,13 +65,7 @@ class SyncService:
|
||||
if not end_date:
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
if not start_date:
|
||||
if last_record_ts:
|
||||
start_date = last_record_ts.strftime("%Y-%m-%d")
|
||||
elif self.adapter.__class__.__name__ == "MiFitnessCloudAdapter":
|
||||
start_date = "2020-01-01"
|
||||
else:
|
||||
start = datetime.strptime(end_date, "%Y-%m-%d") - timedelta(days=30)
|
||||
start_date = start.strftime("%Y-%m-%d")
|
||||
start_date = last_record_ts.strftime("%Y-%m-%d") if last_record_ts else "2020-01-01"
|
||||
|
||||
added = 0
|
||||
updated = 0
|
||||
@@ -158,7 +152,7 @@ class SyncService:
|
||||
force_full: bool = False,
|
||||
) -> dict:
|
||||
"""Synchronous wrapper for sync_data_type.
|
||||
|
||||
|
||||
Use this when calling from synchronous code.
|
||||
"""
|
||||
return asyncio.run(self.sync_data_type(data_type, start_date, end_date, force_full))
|
||||
Reference in new issue
Block a user