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:
Aleksej Kubulashvili committed 2026-03-12 14:12:49 +03:00
1 parent 6771fc841c
commit df914ea56a
23 files changed
+754 -426

No files matched your search

-223
View File
@@ -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()
+70 -60
View File
@@ -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
+7 -13
View File
@@ -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))