""" Garmin sync service. Pulls up to 7 days of daily summaries + activities through the `garminconnect` library and upserts them. The library and real Garmin credentials are required to actually run a sync; without them the endpoint reports a clear error instead of crashing (mirrors the original Node behaviour). Garmin credentials: the app only stores a scrypt *hash* of the Garmin password (so it cannot be recovered), therefore a live sync needs the plaintext garminEmail/garminPassword supplied in the request body. """ import datetime from db import execute, query_one, query_all from config import DB_TYPE from services import health def _set_sync_status(user_id, status, now, **fields): cols = ["user_id", "status", "last_sync_time"] + list(fields.keys()) placeholders = ", ".join(["?"] * len(cols)) if DB_TYPE == "mariadb": updates = ", ".join( f"{c}=VALUES({c})" for c in cols if c != "user_id" ) sql = ( f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) " f"ON DUPLICATE KEY UPDATE {updates}" ) else: updates = ", ".join( f"{c}=excluded.{c}" for c in cols if c != "user_id" ) sql = ( f"INSERT INTO sync_status ({', '.join(cols)}) VALUES ({placeholders}) " f"ON CONFLICT(user_id) DO UPDATE SET {updates}" ) params = [user_id, status, now] + list(fields.values()) execute(sql, params) def get_sync_status(user_id): row = query_one("SELECT * FROM sync_status WHERE user_id = ?", [user_id]) if not row: return { "status": "idle", "lastSyncTime": None, "recordsSynced": 0, "lastError": None, } return { "status": row["status"], "lastSyncTime": row["last_sync_time"], "recordsSynced": row["records_synced"], "lastError": row["last_error"], } def sync_data(user_id, creds): now = datetime.datetime.utcnow().isoformat() _set_sync_status(user_id, "syncing", now, records_synced=0) try: try: from garminconnect import Garmin except ImportError: raise RuntimeError( "GARMIN_LIB_MISSING: 请先运行 `pip install garminconnect` 以启用同步" ) client = Garmin(email=creds["garminEmail"], password=creds["garminPassword"]) client.login() records_synced = 0 for i in range(7): d = datetime.datetime.utcnow() - datetime.timedelta(days=i) date_str = d.strftime("%Y-%m-%d") try: daily = client.get_user_summary(date_str) if daily: sleep_sec = (daily.get("sleep") or {}).get("sleepingSeconds") or daily.get( "sleepingSeconds" ) health.upsert_health_daily( user_id, { "date": date_str, "steps": daily.get("steps"), "heartRate": daily.get("restingHeartRate") or daily.get("averageHeartRate"), "heartRateVariability": daily.get("hrv") or daily.get("heartRateVariability"), "sleepDuration": round(sleep_sec / 3600, 1) if sleep_sec else None, "sleepQuality": (daily.get("sleep") or {}).get("sleepQuality"), "stress": (daily.get("stress") or {}).get("average") or daily.get("averageStress"), "caloriesBurned": (daily.get("calories") or {}).get("total") or daily.get("totalCalories"), }, ) records_synced += 1 activities = client.get_activities(date_str) or [] for a in activities or []: start = a.get("startTimeLocal") or a.get("startTime") start_ms = start and datetime.datetime.strptime( start, "%Y-%m-%dT%H:%M:%S" if "T" in (start or "") else "%Y-%m-%d %H:%M:%S" ).timestamp() if start else None health.insert_activity( user_id, { "activityType": (a.get("activityType") or {}).get("typeKey") or a.get("type") or "unknown", "startTime": start, "endTime": ( start if start_ms is None or not a.get("duration") else datetime.datetime.utcfromtimestamp( start_ms + (a.get("duration") or 0) ).isoformat() ), "duration": a.get("duration"), "distance": a.get("distance"), "calories": a.get("calories"), "heartRateAverage": a.get("averageHR"), "heartRateMax": a.get("maxHR"), }, ) except Exception: # skip a single bad day and continue continue _set_sync_status(user_id, "idle", now, records_synced=records_synced) return { "status": "success", "recordsSynced": records_synced, "message": f"同步完成,新增/更新 {records_synced} 天数据", "lastSyncTime": now, } except Exception as e: message = str(e) _set_sync_status(user_id, "error", now, records_synced=0, last_error=message) return { "status": "error", "recordsSynced": 0, "message": message, "lastSyncTime": now, }