Skip to content

Instantly share code, notes, and snippets.

@benwtrent
Last active July 23, 2026 15:20
Show Gist options
  • Select an option

  • Save benwtrent/e932b9cde865c772e59d86fb46120389 to your computer and use it in GitHub Desktop.

Select an option

Save benwtrent/e932b9cde865c772e59d86fb46120389 to your computer and use it in GitHub Desktop.
garmin connect download and strava upload
#!/usr/bin/env python3
"""Download all .fit files from Garmin Connect."""
import argparse
import getpass
import io
import os
import sys
import time
import zipfile
from pathlib import Path
import garminconnect
PAGE_SIZE = 100
RETRY_DELAY = 5 # seconds between retries on rate-limit / transient errors
MAX_RETRIES = 3
def login(email: str | None, password: str | None) -> garminconnect.Garmin:
email = email or os.environ.get("GARMIN_EMAIL") or input("Garmin email: ")
password = password or os.environ.get("GARMIN_PASSWORD") or getpass.getpass("Garmin password: ")
client = garminconnect.Garmin(email=email, password=password)
client.login()
return client
def fetch_all_activities(client: garminconnect.Garmin) -> list[dict]:
total = client.count_activities()
print(f"Total activities: {total}")
activities = []
start = 0
while start < total:
batch = client.get_activities(start=start, limit=PAGE_SIZE)
if not batch:
break
activities.extend(batch)
start += len(batch)
print(f" fetched {len(activities)}/{total}", end="\r", flush=True)
print()
return activities
def download_fit(client: garminconnect.Garmin, activity_id: str) -> bytes | None:
for attempt in range(1, MAX_RETRIES + 1):
try:
data = client.download_activity(
activity_id,
dl_fmt=garminconnect.Garmin.ActivityDownloadFormat.ORIGINAL,
)
return data
except Exception as exc:
if attempt == MAX_RETRIES:
print(f"\n error: activity {activity_id} failed after {MAX_RETRIES} attempts: {exc}")
return None
print(f"\n retry {attempt}/{MAX_RETRIES} for {activity_id}: {exc}")
time.sleep(RETRY_DELAY * attempt)
return None
def extract_fit_from_zip(zip_bytes: bytes) -> tuple[str, bytes] | None:
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
fit_names = [n for n in zf.namelist() if n.lower().endswith(".fit")]
if not fit_names:
return None
name = fit_names[0]
return name, zf.read(name)
except zipfile.BadZipFile:
# Some activities return the raw .fit bytes directly
if zip_bytes[:4] == b"\x0e\x10\xd3\x07" or zip_bytes[:4] == b"\x0c\x00\x00\x00":
return None
# Try treating as raw fit (FIT magic: first byte is header size, 4th byte is protocol version)
return None
def main() -> None:
parser = argparse.ArgumentParser(description="Download all Garmin .fit files")
parser.add_argument("--email", help="Garmin email (or set GARMIN_EMAIL)")
parser.add_argument("--password", help="Garmin password (or set GARMIN_PASSWORD)")
parser.add_argument("--output-dir", default="garmin_fits", help="Directory to save .fit files (default: garmin_fits)")
parser.add_argument("--delay", type=float, default=0.5, help="Seconds between downloads (default: 0.5)")
parser.add_argument("--skip-existing", action="store_true", default=True, help="Skip already-downloaded files (default: true)")
parser.add_argument("--no-skip-existing", dest="skip_existing", action="store_false")
args = parser.parse_args()
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
print("Logging in...")
client = login(args.email, args.password)
print("Logged in.")
activities = fetch_all_activities(client)
skipped = 0
downloaded = 0
failed = 0
for i, activity in enumerate(activities, 1):
activity_id = str(activity["activityId"])
start_time = activity.get("startTimeLocal", "unknown")[:10] # YYYY-MM-DD
activity_type = activity.get("activityType", {}).get("typeKey", "activity")
base_name = f"{start_time}_{activity_type}_{activity_id}"
fit_path = out_dir / f"{base_name}.fit"
zip_path = out_dir / f"{base_name}.zip"
if args.skip_existing and (fit_path.exists() or zip_path.exists()):
skipped += 1
print(f"[{i}/{len(activities)}] skip {base_name}")
continue
print(f"[{i}/{len(activities)}] downloading {base_name}...", end=" ", flush=True)
zip_bytes = download_fit(client, activity_id)
if zip_bytes is None:
failed += 1
continue
# Try extracting .fit from zip
result = extract_fit_from_zip(zip_bytes)
if result is not None:
_, fit_bytes = result
fit_path.write_bytes(fit_bytes)
print(f"saved {fit_path.name} ({len(fit_bytes):,} bytes)")
downloaded += 1
else:
# Save as zip for manual inspection; Garmin sometimes returns multi-file zips
# or raw bytes we can't cleanly identify
try:
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
zip_path.write_bytes(zip_bytes)
print(f"saved zip {zip_path.name} ({len(zip_bytes):,} bytes) — contains: {zf.namelist()}")
except zipfile.BadZipFile:
# Raw bytes of unknown format — save as-is with .fit extension and hope
fit_path.write_bytes(zip_bytes)
print(f"saved raw {fit_path.name} ({len(zip_bytes):,} bytes)")
downloaded += 1
if args.delay > 0:
time.sleep(args.delay)
print(f"\ndone: {downloaded} downloaded, {skipped} skipped, {failed} failed")
print(f"files in: {out_dir.resolve()}")
if __name__ == "__main__":
main()
install python3
install pip
install https://github.com/cyberjunky/python-garminconnect
run the garmin script
set up strava:
One-time strava setup:
1. Go to https://www.strava.com/settings/api and create an app
2. Set Authorization Callback Domain to localhost
3. Note your Client ID and Client Secret
#!/usr/bin/env python3
"""Upload Garmin .fit files to Strava.
OAuth setup (one-time):
1. Create a Strava API app at https://www.strava.com/settings/api
Set "Authorization Callback Domain" to localhost
2. Run this script — it will print an auth URL
3. Open the URL, authorize, then paste the redirect URL (or just the code=... value)
4. Tokens are saved to ~/.strava_token.json for future runs
"""
import argparse
import http.server
import json
import os
import sys
import time
import threading
import webbrowser
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import stravalib
import stravalib.exc
TOKEN_FILE = Path.home() / ".strava_token.json"
REDIRECT_URI = "http://localhost:8765/callback"
SCOPES = ["activity:write", "activity:read_all"]
# Strava API rate limits: 200 req/15 min, 2000/day. Uploads count per request.
# Conservative: wait if we get rate-limited, cap burst.
UPLOAD_POLL_INTERVAL = 2.0 # seconds between status polls
UPLOAD_TIMEOUT = 60.0 # max seconds to wait per upload
INTER_UPLOAD_DELAY = 1.0 # seconds between uploads (stay under rate limit)
# ---------------------------------------------------------------------------
# OAuth helpers
# ---------------------------------------------------------------------------
class _CallbackHandler(http.server.BaseHTTPRequestHandler):
code: str | None = None
def do_GET(self):
qs = parse_qs(urlparse(self.path).query)
_CallbackHandler.code = (qs.get("code") or [None])[0]
self.send_response(200)
self.end_headers()
self.wfile.write(b"<h2>Authorized! You can close this tab.</h2>")
def log_message(self, *args):
pass # silence request logs
def _capture_code_via_browser(auth_url: str) -> str:
"""Open browser, start local server, capture OAuth code automatically."""
server = http.server.HTTPServer(("localhost", 8765), _CallbackHandler)
server.timeout = 120
print(f"Opening browser for Strava authorization...")
webbrowser.open(auth_url)
while _CallbackHandler.code is None:
server.handle_request()
server.server_close()
return _CallbackHandler.code
def _capture_code_manual(auth_url: str) -> str:
print(f"\nOpen this URL in your browser:\n\n {auth_url}\n")
raw = input("Paste the redirect URL or just the code= value: ").strip()
if raw.startswith("http"):
qs = parse_qs(urlparse(raw).query)
return (qs.get("code") or [None])[0]
return raw
def load_token() -> dict | None:
if TOKEN_FILE.exists():
return json.loads(TOKEN_FILE.read_text())
return None
def save_token(data: dict) -> None:
TOKEN_FILE.write_text(json.dumps(data, indent=2))
TOKEN_FILE.chmod(0o600)
def get_authenticated_client(client_id: int, client_secret: str, force_reauth: bool = False) -> stravalib.Client:
client = stravalib.Client()
token = None if force_reauth else load_token()
if token is None:
# First-time auth
auth_url = client.authorization_url(
client_id=client_id,
redirect_uri=REDIRECT_URI,
scope=SCOPES,
)
try:
code = _capture_code_via_browser(auth_url)
except Exception:
code = _capture_code_manual(auth_url)
token_response = client.exchange_code_for_token(
client_id=client_id,
client_secret=client_secret,
code=code,
)
token = dict(token_response)
save_token(token)
print("Token saved to", TOKEN_FILE)
# Refresh if expired (with 60s buffer)
if token["expires_at"] < time.time() + 60:
print("Refreshing expired access token...")
refreshed = client.refresh_access_token(
client_id=client_id,
client_secret=client_secret,
refresh_token=token["refresh_token"],
)
token = dict(refreshed)
save_token(token)
client.access_token = token["access_token"]
client.refresh_token = token["refresh_token"]
client.token_expires_at = token["expires_at"]
return client
# ---------------------------------------------------------------------------
# Upload logic
# ---------------------------------------------------------------------------
# Map Garmin activity type keys (from filenames) to Strava sport types
GARMIN_TO_STRAVA_TYPE: dict[str, str] = {
"running": "Run",
"trail_running": "TrailRun",
"walking": "Walk",
"hiking": "Hike",
"cycling": "Ride",
"road_biking": "Ride",
"mountain_biking": "MountainBikeRide",
"gravel_cycling": "GravelRide",
"indoor_cycling": "VirtualRide",
"swimming": "Swim",
"open_water_swimming": "Swim",
"strength_training": "WeightTraining",
"yoga": "Yoga",
"elliptical": "Elliptical",
"stair_climbing": "StairStepper",
"rowing": "Rowing",
"indoor_rowing": "Rowing",
"skiing": "AlpineSki",
"cross_country_skiing": "NordicSki",
"snowboarding": "Snowboard",
"paddling": "Kayaking",
"stand_up_paddleboarding": "StandUpPaddling",
}
def activity_type_from_filename(filename: str) -> str | None:
"""Extract Strava sport type from filename like YYYY-MM-DD_<type>_<id>.fit"""
parts = Path(filename).stem.split("_")
if len(parts) < 3:
return None
# type is between first _ and last _
garmin_type = "_".join(parts[1:-1])
return GARMIN_TO_STRAVA_TYPE.get(garmin_type)
def upload_file(
client: stravalib.Client,
fit_path: Path,
state: dict,
dry_run: bool = False,
) -> str:
"""Upload a single .fit file. Returns status: 'uploaded', 'duplicate', 'error', 'skipped'."""
key = fit_path.name
if key in state.get("uploaded", {}):
return "skipped"
activity_type = activity_type_from_filename(fit_path.name)
if dry_run:
print(f" [dry-run] would upload {fit_path.name} as {activity_type or '(auto)'}")
return "skipped"
try:
with fit_path.open("rb") as fh:
uploader = client.upload_activity(
activity_file=fh,
data_type="fit",
external_id=fit_path.stem,
)
# Poll until Strava finishes processing
activity = uploader.wait(timeout=UPLOAD_TIMEOUT, poll_interval=UPLOAD_POLL_INTERVAL)
state.setdefault("uploaded", {})[key] = {
"strava_id": activity.id,
"name": activity.name,
"ts": time.time(),
}
return "uploaded"
except stravalib.exc.ActivityUploadFailed as exc:
msg = str(exc).lower()
if "duplicate" in msg:
state.setdefault("uploaded", {})[key] = {"duplicate": True, "ts": time.time()}
return "duplicate"
state.setdefault("errors", {})[key] = str(exc)
return "error"
except stravalib.exc.RateLimitExceeded as exc:
raise # caller handles
except stravalib.exc.TimeoutExceeded:
state.setdefault("errors", {})[key] = "timeout waiting for processing"
return "error"
except Exception as exc:
state.setdefault("errors", {})[key] = str(exc)
return "error"
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Upload Garmin .fit files to Strava")
parser.add_argument("--client-id", type=int, help="Strava client ID (or set STRAVA_CLIENT_ID)")
parser.add_argument("--client-secret", help="Strava client secret (or set STRAVA_CLIENT_SECRET)")
parser.add_argument("--fit-dir", default="garmin_fits", help="Directory containing .fit files (default: garmin_fits)")
parser.add_argument("--state-file", default="strava_upload_state.json", help="JSON file tracking upload progress (default: strava_upload_state.json)")
parser.add_argument("--delay", type=float, default=INTER_UPLOAD_DELAY, help=f"Seconds between uploads (default: {INTER_UPLOAD_DELAY})")
parser.add_argument("--reauth", action="store_true", help="Force re-authorization (clears saved token)")
parser.add_argument("--dry-run", action="store_true", help="List files without uploading")
args = parser.parse_args()
client_id = args.client_id or int(os.environ.get("STRAVA_CLIENT_ID", 0))
client_secret = args.client_secret or os.environ.get("STRAVA_CLIENT_SECRET", "")
if not client_id or not client_secret:
print("error: --client-id and --client-secret required (or set STRAVA_CLIENT_ID / STRAVA_CLIENT_SECRET)")
print(" Create an app at https://www.strava.com/settings/api")
sys.exit(1)
fit_dir = Path(args.fit_dir)
if not fit_dir.is_dir():
print(f"error: {fit_dir} is not a directory")
sys.exit(1)
fit_files = sorted(fit_dir.glob("*.fit"))
if not fit_files:
print(f"No .fit files found in {fit_dir}")
sys.exit(0)
# Load persisted state
state_path = Path(args.state_file)
state: dict = json.loads(state_path.read_text()) if state_path.exists() else {}
already_done = len(state.get("uploaded", {}))
remaining = [f for f in fit_files if f.name not in state.get("uploaded", {})]
print(f"Found {len(fit_files)} .fit files — {already_done} already uploaded, {len(remaining)} to upload")
if not remaining:
print("Nothing to do.")
return
if not args.dry_run:
client = get_authenticated_client(client_id, client_secret, force_reauth=args.reauth)
print(f"Authenticated.\n")
else:
client = None
counts = {"uploaded": 0, "duplicate": 0, "error": 0, "skipped": 0}
for i, fit_path in enumerate(fit_files, 1):
if fit_path.name in state.get("uploaded", {}):
counts["skipped"] += 1
continue
print(f"[{i}/{len(fit_files)}] {fit_path.name}", end=" ... ", flush=True)
while True: # retry loop for rate limits
try:
result = upload_file(client, fit_path, state, dry_run=args.dry_run)
break
except stravalib.exc.RateLimitExceeded as e:
wait = 60
print(f"\n rate limited — waiting {wait}s", end=" ", flush=True)
time.sleep(wait)
counts[result] += 1
print(result)
# Persist state after every file so progress survives interruption
state_path.write_text(json.dumps(state, indent=2))
if result in ("uploaded",) and args.delay > 0:
time.sleep(args.delay)
print(f"\ndone: {counts['uploaded']} uploaded, {counts['duplicate']} duplicates, "
f"{counts['error']} errors, {counts['skipped']} skipped")
print(f"state saved to: {state_path.resolve()}")
if state.get("errors"):
print(f"\nfailed files:")
for name, err in state["errors"].items():
print(f" {name}: {err}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment