first commit

This commit is contained in:
2026-05-18 12:53:24 -04:00
commit c7f6f68c40
23693 changed files with 8967 additions and 0 deletions

431
sync.py Normal file
View File

@@ -0,0 +1,431 @@
"""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()