1.x updates
This commit is contained in:
430
sync.py
430
sync.py
@@ -1,431 +1,5 @@
|
||||
"""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")
|
||||
|
||||
"""Shim — see openrun.ingest.garmin_api."""
|
||||
from openrun.ingest.garmin_api import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user