"""Sync Garmin Connect data to local SQLite. Usage: uv run sync.py # incremental: last 14 days of wellness, new activities only uv run sync.py --full # full backfill (activities + last 365 days wellness) uv run sync.py --days 90 # backfill last 90 days of wellness uv run sync.py --no-details # skip per-activity detail/splits (faster) Run auth.py first to log in. """ from __future__ import annotations import argparse import dataclasses import json import sqlite3 import sys import time from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Callable import garth from garth.exc import GarthHTTPError from db import connect, get_state, set_state TOKEN_DIR = Path(__file__).parent / ".secrets" # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _to_jsonable(obj: Any) -> Any: """Recursively convert dataclasses/datetimes/dates to JSON-safe values.""" if dataclasses.is_dataclass(obj) and not isinstance(obj, type): return {f.name: _to_jsonable(getattr(obj, f.name)) for f in dataclasses.fields(obj)} if isinstance(obj, (datetime, date)): return obj.isoformat() if isinstance(obj, dict): return {k: _to_jsonable(v) for k, v in obj.items()} if isinstance(obj, (list, tuple)): return [_to_jsonable(v) for v in obj] return obj def _dump(obj: Any) -> str: return json.dumps(_to_jsonable(obj), separators=(",", ":"), default=str) def _safe_call(fn: Callable[[], Any], description: str) -> Any: """Run an API call with one retry on transient errors; return None on failure.""" for attempt in (1, 2): try: return fn() except GarthHTTPError as exc: status = getattr(getattr(exc, "error", None), "response", None) code = getattr(status, "status_code", None) if code == 404: return None # data doesn't exist for this date if attempt == 1 and code in (429, 500, 502, 503, 504): print(f" retry {description} ({code})", file=sys.stderr) time.sleep(2) continue print(f" ! {description} failed: {exc}", file=sys.stderr) return None except Exception as exc: # noqa: BLE001 print(f" ! {description} failed: {exc}", file=sys.stderr) return None return None # --------------------------------------------------------------------------- # activity sync # --------------------------------------------------------------------------- def sync_activities(conn: sqlite3.Connection, *, full: bool, fetch_details: bool) -> int: """Page through the activity list and upsert anything new (or all, in full mode).""" existing_ids: set[int] = { row["activity_id"] for row in conn.execute("SELECT activity_id FROM activities") } page_size = 100 offset = 0 inserted = 0 detail_count = 0 while True: page = _safe_call( lambda: garth.connectapi( "/activitylist-service/activities/search/activities", params={"limit": page_size, "start": offset}, ), f"activity list start={offset}", ) if not page: break new_in_page = 0 for raw in page: aid = raw.get("activityId") if aid is None: continue if aid in existing_ids and not full: continue new_in_page += 1 existing_ids.add(aid) atype = raw.get("activityType") or {} conn.execute( """ INSERT INTO activities ( activity_id, start_time_local, start_time_gmt, activity_type, activity_name, distance_m, duration_s, moving_duration_s, avg_speed_mps, max_speed_mps, avg_hr, max_hr, calories, elevation_gain_m, elevation_loss_m, training_load, aerobic_te, anaerobic_te, vo2_max, raw, fetched_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(activity_id) DO UPDATE SET activity_name=excluded.activity_name, distance_m=excluded.distance_m, duration_s=excluded.duration_s, raw=excluded.raw, fetched_at=excluded.fetched_at """, ( aid, raw.get("startTimeLocal"), raw.get("startTimeGMT"), atype.get("typeKey"), raw.get("activityName"), raw.get("distance"), raw.get("duration"), raw.get("movingDuration"), raw.get("averageSpeed"), raw.get("maxSpeed"), raw.get("averageHR"), raw.get("maxHR"), raw.get("calories"), raw.get("elevationGain"), raw.get("elevationLoss"), raw.get("activityTrainingLoad"), raw.get("aerobicTrainingEffect"), raw.get("anaerobicTrainingEffect"), raw.get("vO2MaxValue"), _dump(raw), ), ) inserted += 1 if fetch_details: if _fetch_activity_details(conn, aid): detail_count += 1 conn.commit() print(f" activities: page offset={offset} → {new_in_page} new (total inserted: {inserted})") if not full and new_in_page == 0: break # incremental: stop once we hit fully-known territory if len(page) < page_size: break # last page offset += page_size if fetch_details: print(f" fetched details/splits for {detail_count} activities") return inserted def _fetch_activity_details(conn: sqlite3.Connection, aid: int) -> bool: """Fetch detail and splits for a single activity. Returns True on success.""" detail = _safe_call( lambda: garth.connectapi(f"/activity-service/activity/{aid}"), f"activity detail {aid}", ) if detail: summary = detail.get("summaryDTO", {}) or {} conn.execute( """ UPDATE activities SET training_load = COALESCE(?, training_load), aerobic_te = COALESCE(?, aerobic_te), anaerobic_te = COALESCE(?, anaerobic_te), vo2_max = COALESCE(?, vo2_max), raw = ? WHERE activity_id = ? """, ( summary.get("activityTrainingLoad"), summary.get("trainingEffect"), summary.get("anaerobicTrainingEffect"), detail.get("vO2MaxValue") or summary.get("vO2MaxValue"), _dump(detail), aid, ), ) splits = _safe_call( lambda: garth.connectapi(f"/activity-service/activity/{aid}/splits"), f"activity splits {aid}", ) if splits and isinstance(splits, dict): lap_dtos = splits.get("lapDTOs", []) or [] for idx, lap in enumerate(lap_dtos): conn.execute( """ INSERT OR REPLACE INTO activity_splits (activity_id, split_index, distance_m, duration_s, avg_hr, avg_speed_mps, elevation_gain_m, raw) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( aid, idx, lap.get("distance"), lap.get("duration"), lap.get("averageHR"), lap.get("averageSpeed"), lap.get("elevationGain"), _dump(lap), ), ) return detail is not None # --------------------------------------------------------------------------- # wellness sync (one function per data type) # --------------------------------------------------------------------------- def sync_steps(conn: sqlite3.Connection, end: date, period: int) -> int: rows = _safe_call(lambda: garth.DailySteps.list(end=end, period=period), "DailySteps") or [] for r in rows: conn.execute( """INSERT OR REPLACE INTO daily_steps (calendar_date, total_steps, step_goal, distance_m, raw, fetched_at) VALUES (?, ?, ?, ?, ?, datetime('now'))""", (r.calendar_date.isoformat(), r.total_steps, r.step_goal, r.total_distance, _dump(r)), ) conn.commit() return len(rows) def sync_sleep(conn: sqlite3.Connection, end: date, period: int) -> int: rows = _safe_call(lambda: garth.DailySleep.list(end=end, period=period), "DailySleep") or [] for r in rows: conn.execute( """INSERT OR REPLACE INTO daily_sleep (calendar_date, sleep_score, raw, fetched_at) VALUES (?, ?, ?, datetime('now'))""", (r.calendar_date.isoformat(), r.value, _dump(r)), ) conn.commit() return len(rows) def sync_stress(conn: sqlite3.Connection, end: date, period: int) -> int: rows = _safe_call(lambda: garth.DailyStress.list(end=end, period=period), "DailyStress") or [] for r in rows: conn.execute( """INSERT OR REPLACE INTO daily_stress (calendar_date, avg_stress, raw, fetched_at) VALUES (?, ?, ?, datetime('now'))""", (r.calendar_date.isoformat(), r.overall_stress_level, _dump(r)), ) conn.commit() return len(rows) def sync_hrv(conn: sqlite3.Connection, end: date, period: int) -> int: rows = _safe_call(lambda: garth.DailyHRV.list(end=end, period=period), "DailyHRV") or [] for r in rows: conn.execute( """INSERT OR REPLACE INTO daily_hrv (calendar_date, weekly_avg, last_night_avg, last_night_5min, status, raw, fetched_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""", ( r.calendar_date.isoformat(), r.weekly_avg, r.last_night_avg, r.last_night_5_min_high, r.status, _dump(r), ), ) conn.commit() return len(rows) def sync_intensity_minutes(conn: sqlite3.Connection, end: date, period: int) -> int: rows = _safe_call( lambda: garth.DailyIntensityMinutes.list(end=end, period=period), "DailyIntensityMinutes", ) or [] for r in rows: conn.execute( """INSERT OR REPLACE INTO daily_intensity_minutes (calendar_date, moderate_minutes, vigorous_minutes, raw, fetched_at) VALUES (?, ?, ?, ?, datetime('now'))""", (r.calendar_date.isoformat(), r.moderate_value, r.vigorous_value, _dump(r)), ) conn.commit() return len(rows) def sync_body_battery(conn: sqlite3.Connection, end: date, period: int) -> int: """Per-day calls — slow for large backfills but the only path for BB aggregates.""" inserted = 0 for i in range(period): d = end - timedelta(days=i) existing = conn.execute( "SELECT 1 FROM daily_body_battery WHERE calendar_date = ?", (d.isoformat(),) ).fetchone() if existing and i > 2: # always refresh last 3 days, skip older if already present continue data = _safe_call( lambda d=d: garth.connectapi( f"/wellness-service/wellness/bodyBattery/reports/daily", params={"startDate": d.isoformat(), "endDate": d.isoformat()}, ), f"body battery {d}", ) if not data: continue item = data[0] if isinstance(data, list) and data else data if not isinstance(item, dict): continue conn.execute( """INSERT OR REPLACE INTO daily_body_battery (calendar_date, charged, drained, highest, lowest, raw, fetched_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))""", ( d.isoformat(), item.get("charged"), item.get("drained"), item.get("highestBatteryLevel") or item.get("highest"), item.get("lowestBatteryLevel") or item.get("lowest"), _dump(item), ), ) inserted += 1 conn.commit() return inserted def sync_resting_hr(conn: sqlite3.Connection, end: date, period: int) -> int: """Resting HR per day via user-summary endpoint.""" inserted = 0 for i in range(period): d = end - timedelta(days=i) if i > 2: existing = conn.execute( "SELECT 1 FROM daily_resting_hr WHERE calendar_date = ?", (d.isoformat(),) ).fetchone() if existing: continue data = _safe_call( lambda d=d: garth.connectapi( f"/usersummary-service/usersummary/daily/{garth.client.username}", params={"calendarDate": d.isoformat()}, ), f"daily summary {d}", ) if not isinstance(data, dict): continue rhr = data.get("restingHeartRate") if rhr is None: continue conn.execute( """INSERT OR REPLACE INTO daily_resting_hr (calendar_date, resting_hr, raw, fetched_at) VALUES (?, ?, ?, datetime('now'))""", (d.isoformat(), rhr, _dump(data)), ) inserted += 1 conn.commit() return inserted # --------------------------------------------------------------------------- # main # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--full", action="store_true", help="Full backfill (365 days of wellness)") parser.add_argument("--days", type=int, default=None, help="Days of wellness to backfill") parser.add_argument("--no-details", action="store_true", help="Skip per-activity detail/splits") parser.add_argument( "--skip-activities", action="store_true", help="Wellness only, no activity sync" ) args = parser.parse_args() if not TOKEN_DIR.exists() or not any(TOKEN_DIR.iterdir()): print("No tokens found — run `uv run auth.py` first.", file=sys.stderr) sys.exit(1) garth.resume(TOKEN_DIR) days = args.days if args.days is not None else (365 if args.full else 14) end = date.today() conn = connect() if not args.skip_activities: print(f"→ activities (full={args.full}, details={not args.no_details})") n = sync_activities(conn, full=args.full, fetch_details=not args.no_details) print(f" done: {n} activities upserted") wellness = [ ("steps", sync_steps), ("sleep score", sync_sleep), ("stress", sync_stress), ("HRV", sync_hrv), ("intensity minutes", sync_intensity_minutes), ("resting HR", sync_resting_hr), ("body battery", sync_body_battery), ] for name, fn in wellness: print(f"→ {name} (last {days} days)") n = fn(conn, end, days) print(f" done: {n} rows") set_state(conn, "last_sync_utc", datetime.utcnow().isoformat(timespec="seconds")) conn.commit() conn.close() print("✓ sync complete") if __name__ == "__main__": main()