432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""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()
|