diff --git a/bincio/extract/garmin_sync.py b/bincio/extract/garmin_sync.py new file mode 100644 index 0000000..395c886 --- /dev/null +++ b/bincio/extract/garmin_sync.py @@ -0,0 +1,196 @@ +"""Garmin Connect incremental sync — generator-based, mirrors strava_sync_iter. + +Sync state is stored in {user_dir}/garmin_sync.json: + { + "last_sync_at": "2026-04-12" ← date of last successful sync (YYYY-MM-DD) + } + +We query Garmin for all activities from (last_sync_at - 1 day) to today, +then skip any that already exist (FileExistsError from ingest_parsed). +The -1 day buffer catches activities that were saved to Garmin slightly +after their recorded end time crosses midnight. + +Each yielded dict has a ``type`` key: + - ``"fetching"`` — about to contact Garmin + - ``"progress"`` — one activity processed; keys: n, total, name, status, garmin_id + - ``"done"`` — final summary; keys: imported, skipped, error_count, errors + - ``"error"`` — fatal error; key: message +""" + +from __future__ import annotations + +import io +import json +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Generator + +_SYNC_FILE = "garmin_sync.json" + + +# ── Sync state helpers ──────────────────────────────────────────────────────── + +def _load_sync_state(user_dir: Path) -> dict: + p = user_dir / _SYNC_FILE + if not p.exists(): + return {} + try: + return json.loads(p.read_text()) + except Exception: + return {} + + +def _save_sync_state(user_dir: Path, state: dict) -> None: + (user_dir / _SYNC_FILE).write_text(json.dumps(state, indent=2)) + + +# ── FIT extraction from ZIP ─────────────────────────────────────────────────── + +def _extract_fit(zip_bytes: bytes) -> tuple[bytes, str]: + """Return (fit_bytes, filename) from a Garmin activity ZIP. + + Garmin always packages the original FIT as the first .fit entry. + Raises ValueError if no FIT file is found. + """ + 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: + raise ValueError(f"No FIT file in archive. Contents: {zf.namelist()}") + name = fit_names[0] + return zf.read(name), name + + +# ── Main generator ──────────────────────────────────────────────────────────── + +def garmin_sync_iter( + data_dir: Path, + user_dir: Path, +) -> Generator[dict, None, None]: + """Fetch new activities from Garmin Connect and ingest them. + + Args: + data_dir: Root data directory (used for encryption key lookup). + user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.). + """ + from bincio.extract.garmin_api import GarminError, get_client + from bincio.extract.ingest import ingest_parsed + from bincio.extract.parsers.fit import FitParser + + # ── Login ────────────────────────────────────────────────────────────────── + try: + client = get_client(data_dir, user_dir) + except GarminError as exc: + yield {"type": "error", "message": str(exc)} + return + + yield {"type": "fetching"} + + # ── Determine date range ─────────────────────────────────────────────────── + state = _load_sync_state(user_dir) + last = state.get("last_sync_at") + + if last: + # Start one day before last sync to catch edge cases around midnight + start_dt = datetime.fromisoformat(last) - timedelta(days=1) + else: + # First sync: import everything Garmin has + start_dt = datetime(2000, 1, 1) + + start_date = start_dt.strftime("%Y-%m-%d") + end_date = datetime.now().strftime("%Y-%m-%d") + + # ── Fetch activity list ──────────────────────────────────────────────────── + try: + activities = client.get_activities_by_date( + startdate=start_date, + enddate=end_date, + ) + except Exception as exc: + yield {"type": "error", "message": f"Failed to fetch activity list: {exc}"} + return + + total = len(activities) + imported = 0 + skipped = 0 + errors: list[str] = [] + parser = FitParser() + + # ── Process each activity ────────────────────────────────────────────────── + for n, meta in enumerate(activities, 1): + garmin_id = meta.get("activityId") + name = meta.get("activityName") or "Untitled" + + try: + # Download original FIT (wrapped in a ZIP by Garmin) + try: + zip_bytes = client.download_activity( + garmin_id, + dl_fmt=client.ActivityDownloadFormat.ORIGINAL, + ) + except Exception as exc: + raise RuntimeError(f"Download failed: {exc}") from exc + + try: + fit_bytes, fit_name = _extract_fit(zip_bytes) + except Exception as exc: + raise RuntimeError(f"ZIP extraction failed: {exc}") from exc + + # Parse FIT — pass a dummy Path so the parser has a filename for + # any format-detection logic; raw bytes are the actual data. + fake_path = Path(fit_name) + try: + parsed = parser.parse(fake_path, fit_bytes) + except Exception as exc: + raise RuntimeError(f"FIT parse error: {exc}") from exc + + # Ingest — raises FileExistsError if already present (dedup) + ingest_parsed(parsed, user_dir) + imported += 1 + yield { + "type": "progress", + "n": n, "total": total, "name": name, + "status": "imported", + "garmin_id": garmin_id, + } + + except FileExistsError: + skipped += 1 + yield { + "type": "progress", + "n": n, "total": total, "name": name, + "status": "skipped", + "garmin_id": garmin_id, + } + + except Exception as exc: + errors.append(f"{garmin_id} ({name}): {type(exc).__name__}: {exc}") + yield { + "type": "progress", + "n": n, "total": total, "name": name, + "status": "error", + "garmin_id": garmin_id, + } + + # ── Persist sync state ───────────────────────────────────────────────────── + state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d") + _save_sync_state(user_dir, state) + + yield { + "type": "done", + "imported": imported, + "skipped": skipped, + "error_count": len(errors), + "errors": errors[:5], + } + + +def run_garmin_sync(data_dir: Path, user_dir: Path) -> dict: + """Blocking wrapper around garmin_sync_iter for non-SSE callers.""" + result: dict = {} + for event in garmin_sync_iter(data_dir, user_dir): + if event["type"] == "done": + result = event + elif event["type"] == "error": + raise RuntimeError(event["message"]) + return result diff --git a/bincio/serve/server.py b/bincio/serve/server.py index e8031ca..db95d01 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -1034,3 +1034,111 @@ async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None) raise HTTPException(502, str(e)) _trigger_rebuild(user.handle) return JSONResponse(result) + + +# ── Garmin Connect endpoints ────────────────────────────────────────────────── + +def _garmin_user_message(exc: Exception) -> str: + """Return a human-friendly error message for common Garmin login failures.""" + msg = str(exc) + fallback = ( + " In the meantime, you can export your activities from Garmin Connect " + "(garmin.com → Activities → Export) or Garmin Express as FIT files " + "and upload them directly." + ) + if "429" in msg or "rate limit" in msg.lower(): + return ( + "Garmin is rate-limiting this server's IP address (HTTP 429). " + "Wait a few hours and try again." + fallback + ) + if "403" in msg: + return ( + "Cloudflare is blocking the login request (HTTP 403). " + "This is a known upstream issue — try again later or update garminconnect " + "(uv sync --extra garmin)." + fallback + ) + if "GARMIN Authentication Application" in msg or "unexpected title" in msg.lower(): + return ( + "Garmin's login page returned a CAPTCHA or MFA challenge that " + "cannot be completed automatically. Try again later, or disable " + "two-factor authentication on your Garmin account." + fallback + ) + return f"Login failed: {exc}" + fallback + +@app.get("/api/garmin/status") +async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Return whether Garmin credentials are stored for the current user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + from bincio.extract.garmin_api import has_credentials + from bincio.extract.garmin_sync import _load_sync_state + connected = has_credentials(dd) + last_sync = None + if connected: + state = _load_sync_state(dd) + last_sync = state.get("last_sync_at") + return JSONResponse({"connected": connected, "last_sync": last_sync}) + + +@app.post("/api/garmin/connect") +async def garmin_connect( + request: Request, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Test Garmin login with the supplied credentials and save them on success.""" + user = _require_user(bincio_session) + body = await request.json() + email = (body.get("email") or "").strip() + password = body.get("password") or "" + if not email or not password: + raise HTTPException(400, "email and password are required") + + data_dir = _get_data_dir() + user_dir = data_dir / user.handle + from bincio.extract.garmin_api import GarminError, test_login + try: + info = test_login(data_dir, user_dir, email, password) + except GarminError as exc: + raise HTTPException(400, _garmin_user_message(exc)) + return JSONResponse({"ok": True, **info}) + + +@app.post("/api/garmin/disconnect") +async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: + """Remove stored Garmin credentials and session for the current user.""" + user = _require_user(bincio_session) + dd = _get_data_dir() / user.handle + from bincio.extract.garmin_api import delete_credentials + delete_credentials(dd) + return JSONResponse({"ok": True}) + + +@app.get("/api/garmin/sync/stream") +async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: + """SSE endpoint — streams per-activity Garmin sync progress.""" + user = _require_user(bincio_session) + data_dir = _get_data_dir() + user_dir = data_dir / user.handle + + from bincio.extract.garmin_api import GarminError, has_credentials + if not has_credentials(user_dir): + raise HTTPException(400, "No Garmin credentials stored — connect first") + + from bincio.extract.garmin_sync import garmin_sync_iter + + def event_stream(): + try: + for event in garmin_sync_iter(data_dir, user_dir): + if event["type"] == "done": + _trigger_rebuild(user.handle) + yield f"data: {json.dumps(event)}\n\n" + except GarminError as exc: + yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': _garmin_user_message(exc)})}\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) diff --git a/docs/garmin_connect_disclaimer.md b/docs/garmin_connect_disclaimer.md index 8d88475..5a00a0c 100644 --- a/docs/garmin_connect_disclaimer.md +++ b/docs/garmin_connect_disclaimer.md @@ -42,6 +42,40 @@ This feature relies on a reverse-engineered interface that: BincioActivity takes no responsibility for account restrictions or bans that may result from using this feature. +### Cloudflare bot protection and rate limiting + +Garmin's login page (`sso.garmin.com`) is protected by Cloudflare, which +periodically blocks automated login attempts. When this happens, the sync +feature will fail at the login step with a "Login failed" error — even if +your credentials are correct. + +The underlying `garth` library tries three login strategies in sequence. +A blocked session typically looks like this in the server logs: + +``` +mobile+cffi returned 429: Mobile login returned 429 — IP rate limited by Garmin +mobile+requests failed: Mobile login failed (non-JSON): HTTP 403 +widget+cffi failed: Widget login: unexpected title 'GARMIN Authentication Application' +``` + +What each error means: +- **429** — Garmin is rate-limiting the server's IP address +- **403** — Cloudflare is blocking the request outright +- **unexpected title 'GARMIN Authentication Application'** — the login flow hit a + CAPTCHA or MFA challenge page that the library cannot handle automatically + +This is an upstream issue outside BincioActivity's control. The underlying +`garminconnect`/`garth` library usually releases a fix within days to weeks. +The workaround is to update those packages on the server: + +```bash +uv sync --extra garmin +``` + +If login consistently fails despite updating, check the +[garminconnect issue tracker](https://github.com/cyberjunky/python-garminconnect/issues) +for the current status. + ### Two-factor authentication (2FA) If your Garmin account has 2FA enabled, this feature may not work or may diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index d224300..befcaae 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -276,6 +276,16 @@ try {

Import your full Strava archive

+ @@ -356,6 +366,47 @@ try {

+ + + )} @@ -476,12 +527,15 @@ try { const viewFile = document.getElementById('upload-view-file'); const viewStrava = document.getElementById('upload-view-strava'); const viewZip = document.getElementById('upload-view-zip'); + const viewGarmin = document.getElementById('upload-view-garmin'); const chooseFile = document.getElementById('upload-choose-file'); const chooseStrava = document.getElementById('upload-choose-strava'); const chooseZip = document.getElementById('upload-choose-zip'); + const chooseGarmin = document.getElementById('upload-choose-garmin'); const backFile = document.getElementById('upload-back-file'); const backStrava = document.getElementById('upload-back-strava'); const backZip = document.getElementById('upload-back-zip'); + const backGarmin = document.getElementById('upload-back-garmin'); const zipDrop = document.getElementById('zip-drop'); const zipInput = document.getElementById('zip-input'); const zipLabel = document.getElementById('zip-label'); @@ -501,6 +555,16 @@ try { const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn'); const stravaLastSync = document.getElementById('strava-last-sync'); const stravaChooseSub = document.getElementById('strava-choose-sub'); + const garminStatus = document.getElementById('garmin-status'); + const garminConnect = document.getElementById('garmin-connect-area'); + const garminSync = document.getElementById('garmin-sync-area'); + const garminEmail = document.getElementById('garmin-email'); + const garminPassword = document.getElementById('garmin-password'); + const garminConnBtn = document.getElementById('garmin-connect-btn'); + const garminSyncBtn = document.getElementById('garmin-sync-btn'); + const garminDisconnBtn = document.getElementById('garmin-disconnect-btn'); + const garminLastSync = document.getElementById('garmin-last-sync'); + const garminChooseSub = document.getElementById('garmin-choose-sub'); // ── view helpers ────────────────────────────────────────────────────── function showView(name) { @@ -508,6 +572,7 @@ try { viewFile.style.display = name === 'file' ? '' : 'none'; viewStrava.style.display = name === 'strava' ? '' : 'none'; viewZip.style.display = name === 'zip' ? '' : 'none'; + viewGarmin.style.display = name === 'garmin' ? '' : 'none'; } function openModal() { @@ -531,6 +596,7 @@ try { backFile.addEventListener('click', () => showView('choose')); backStrava.addEventListener('click', () => showView('choose')); backZip.addEventListener('click', () => showView('choose')); + backGarmin.addEventListener('click', () => showView('choose')); // ── file upload ─────────────────────────────────────────────────────── drop.addEventListener('click', () => input.click()); @@ -845,6 +911,137 @@ try { doZipUpload(e.dataTransfer?.files?.[0]); }); + // ── Garmin Connect ──────────────────────────────────────────────────── + async function loadGarminStatus() { + try { + const r = await fetch(`${editUrl}/api/garmin/status`, { credentials: 'include' }); + if (!r.ok) throw new Error(); + const d = await r.json(); + garminChooseSub.textContent = d.connected ? 'Connected' : 'Not connected'; + garminConnect.style.display = d.connected ? 'none' : ''; + garminSync.style.display = d.connected ? '' : 'none'; + if (d.last_sync) garminLastSync.textContent = new Date(d.last_sync).toLocaleString(); + } catch (_) { + garminChooseSub.textContent = 'Unavailable'; + } + } + loadGarminStatus(); + + chooseGarmin.addEventListener('click', () => { + garminStatus.textContent = ''; + showView('garmin'); + }); + + garminConnBtn.addEventListener('click', async () => { + const email = garminEmail.value.trim(); + const password = garminPassword.value; + if (!email || !password) { + garminStatus.textContent = 'Enter email and password.'; + garminStatus.style.color = '#f87171'; + return; + } + garminConnBtn.disabled = true; + garminConnBtn.textContent = 'Connecting…'; + garminStatus.textContent = 'Contacting Garmin — this may take up to a minute…'; + garminStatus.style.color = '#a1a1aa'; + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 90_000); + const r = await fetch(`${editUrl}/api/garmin/connect`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + signal: controller.signal, + }); + clearTimeout(timeout); + const d = await r.json(); + if (!r.ok) { + garminStatus.textContent = 'Error: ' + (d.detail || 'Login failed'); + garminStatus.style.color = '#f87171'; + } else { + garminPassword.value = ''; + garminStatus.textContent = `Connected as ${d.display_name || email}!`; + garminStatus.style.color = '#4ade80'; + garminConnect.style.display = 'none'; + garminSync.style.display = ''; + garminLastSync.textContent = 'never'; + garminChooseSub.textContent = 'Connected'; + } + } catch (e) { + const msg = e.name === 'AbortError' + ? 'Timed out — Garmin login is taking too long. Try again later.' + : 'Error: ' + e.message; + garminStatus.textContent = msg; + garminStatus.style.color = '#f87171'; + } finally { + garminConnBtn.disabled = false; + garminConnBtn.textContent = 'Connect'; + } + }); + + garminSyncBtn.addEventListener('click', () => { + garminSyncBtn.disabled = true; + garminSyncBtn.textContent = 'Syncing…'; + garminStatus.textContent = ''; + garminStatus.style.color = ''; + + const es = new EventSource(`${editUrl}/api/garmin/sync/stream`, { withCredentials: true }); + es.onmessage = e => { + try { + const d = JSON.parse(e.data); + if (d.type === 'fetching') { + garminStatus.textContent = 'Fetching activity list from Garmin…'; + } else if (d.type === 'progress') { + const pct = Math.round((d.n / d.total) * 100); + const icon = d.status === 'imported' ? '↓' : d.status === 'error' ? '✗' : '·'; + garminStatus.textContent = `${icon} ${d.n}/${d.total} (${pct}%) — ${d.name}`; + } else if (d.type === 'done') { + es.close(); + garminLastSync.textContent = new Date().toLocaleString(); + const errNote = d.error_count ? `, ${d.error_count} errors` : ''; + garminStatus.textContent = `Done — ${d.imported} imported, ${d.skipped} already up to date${errNote}.`; + garminStatus.style.color = '#4ade80'; + garminSyncBtn.disabled = false; + garminSyncBtn.textContent = 'Sync now'; + } else if (d.type === 'error') { + es.close(); + garminStatus.textContent = 'Error: ' + d.message; + garminStatus.style.color = '#f87171'; + garminSyncBtn.disabled = false; + garminSyncBtn.textContent = 'Sync now'; + } + } catch (_) {} + }; + es.onerror = () => { + if (garminSyncBtn.disabled) { + garminStatus.textContent = 'Connection lost. Check logs.'; + garminStatus.style.color = '#f87171'; + garminSyncBtn.disabled = false; + garminSyncBtn.textContent = 'Sync now'; + } + es.close(); + }; + }); + + garminDisconnBtn.addEventListener('click', async () => { + garminDisconnBtn.disabled = true; + garminStatus.textContent = ''; + try { + await fetch(`${editUrl}/api/garmin/disconnect`, { method: 'POST', credentials: 'include' }); + garminSync.style.display = 'none'; + garminConnect.style.display = ''; + garminStatus.textContent = 'Disconnected.'; + garminStatus.style.color = '#a1a1aa'; + garminChooseSub.textContent = 'Not connected'; + } catch (e) { + garminStatus.textContent = 'Error: ' + e.message; + garminStatus.style.color = '#f87171'; + } finally { + garminDisconnBtn.disabled = false; + } + }); + // Handle ?strava= param set by the callback redirect (popup scenario) const sp = new URLSearchParams(window.location.search); if (sp.has('strava')) {