garmin sync first attempt

This commit is contained in:
Davide Scaini
2026-04-12 15:36:21 +02:00
parent 6c431e8821
commit f003fdd89f
4 changed files with 535 additions and 0 deletions
+196
View File
@@ -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
+108
View File
@@ -1034,3 +1034,111 @@ async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)
raise HTTPException(502, str(e)) raise HTTPException(502, str(e))
_trigger_rebuild(user.handle) _trigger_rebuild(user.handle)
return JSONResponse(result) 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"},
)
+34
View File
@@ -42,6 +42,40 @@ This feature relies on a reverse-engineered interface that:
BincioActivity takes no responsibility for account restrictions or bans BincioActivity takes no responsibility for account restrictions or bans
that may result from using this feature. 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) ### Two-factor authentication (2FA)
If your Garmin account has 2FA enabled, this feature may not work or may If your Garmin account has 2FA enabled, this feature may not work or may
+197
View File
@@ -276,6 +276,16 @@ try {
<p class="text-xs text-zinc-500">Import your full Strava archive</p> <p class="text-xs text-zinc-500">Import your full Strava archive</p>
</div> </div>
</button> </button>
<button
id="upload-choose-garmin"
class="flex items-center gap-3 p-4 rounded-lg border border-zinc-700 hover:border-zinc-500 hover:bg-zinc-800 transition-colors text-left"
>
<span class="text-2xl">⌚</span>
<div>
<p class="text-sm font-medium text-white">Sync from Garmin Connect</p>
<p id="garmin-choose-sub" class="text-xs text-zinc-500">Checking…</p>
</div>
</button>
</div> </div>
</div> </div>
@@ -356,6 +366,47 @@ try {
</label> </label>
<p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" style="min-height: 1.25rem"></p> <p id="zip-status" class="mt-3 text-xs text-center leading-relaxed" style="min-height: 1.25rem"></p>
</div> </div>
<!-- View: Garmin Connect sync -->
<div id="upload-view-garmin" style="display:none">
<button id="upload-back-garmin" class="text-xs text-zinc-500 hover:text-white mb-3 transition-colors">← Back</button>
<div class="rounded-lg border border-amber-800/50 bg-amber-950/30 p-3 mb-4 text-xs text-amber-300 leading-relaxed">
⚠ Garmin Connect has no official API. Your credentials are encrypted at rest and used to log in on your behalf. <a href={`${baseUrl}about/`} class="underline hover:text-amber-100">Learn more</a>.
</div>
<!-- Not connected -->
<div id="garmin-connect-area" style="display:none">
<p class="text-sm text-zinc-400 mb-3">Enter your Garmin Connect credentials to sync activities.</p>
<input
id="garmin-email"
type="email"
placeholder="Email"
class="w-full mb-2 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
/>
<input
id="garmin-password"
type="password"
placeholder="Password"
class="w-full mb-3 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-500"
/>
<button
id="garmin-connect-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-blue-600 hover:bg-blue-500 text-white transition-colors"
>Connect</button>
</div>
<!-- Connected -->
<div id="garmin-sync-area" style="display:none">
<p class="text-xs text-zinc-500 mb-1">Last sync: <span id="garmin-last-sync">never</span></p>
<button
id="garmin-sync-btn"
class="w-full py-2 px-4 rounded-lg font-medium text-sm bg-zinc-700 hover:bg-zinc-600 text-white transition-colors mt-2"
>Sync now</button>
<button
id="garmin-disconnect-btn"
class="w-full mt-2 py-1.5 px-3 rounded-lg text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
>Disconnect</button>
</div>
<p id="garmin-status" class="mt-3 text-xs text-center" style="min-height: 1.25rem"></p>
</div>
</div> </div>
</div> </div>
)} )}
@@ -476,12 +527,15 @@ try {
const viewFile = document.getElementById('upload-view-file'); const viewFile = document.getElementById('upload-view-file');
const viewStrava = document.getElementById('upload-view-strava'); const viewStrava = document.getElementById('upload-view-strava');
const viewZip = document.getElementById('upload-view-zip'); const viewZip = document.getElementById('upload-view-zip');
const viewGarmin = document.getElementById('upload-view-garmin');
const chooseFile = document.getElementById('upload-choose-file'); const chooseFile = document.getElementById('upload-choose-file');
const chooseStrava = document.getElementById('upload-choose-strava'); const chooseStrava = document.getElementById('upload-choose-strava');
const chooseZip = document.getElementById('upload-choose-zip'); const chooseZip = document.getElementById('upload-choose-zip');
const chooseGarmin = document.getElementById('upload-choose-garmin');
const backFile = document.getElementById('upload-back-file'); const backFile = document.getElementById('upload-back-file');
const backStrava = document.getElementById('upload-back-strava'); const backStrava = document.getElementById('upload-back-strava');
const backZip = document.getElementById('upload-back-zip'); const backZip = document.getElementById('upload-back-zip');
const backGarmin = document.getElementById('upload-back-garmin');
const zipDrop = document.getElementById('zip-drop'); const zipDrop = document.getElementById('zip-drop');
const zipInput = document.getElementById('zip-input'); const zipInput = document.getElementById('zip-input');
const zipLabel = document.getElementById('zip-label'); const zipLabel = document.getElementById('zip-label');
@@ -501,6 +555,16 @@ try {
const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn'); const stravaResetHardBtn = document.getElementById('strava-reset-hard-btn');
const stravaLastSync = document.getElementById('strava-last-sync'); const stravaLastSync = document.getElementById('strava-last-sync');
const stravaChooseSub = document.getElementById('strava-choose-sub'); 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 ────────────────────────────────────────────────────── // ── view helpers ──────────────────────────────────────────────────────
function showView(name) { function showView(name) {
@@ -508,6 +572,7 @@ try {
viewFile.style.display = name === 'file' ? '' : 'none'; viewFile.style.display = name === 'file' ? '' : 'none';
viewStrava.style.display = name === 'strava' ? '' : 'none'; viewStrava.style.display = name === 'strava' ? '' : 'none';
viewZip.style.display = name === 'zip' ? '' : 'none'; viewZip.style.display = name === 'zip' ? '' : 'none';
viewGarmin.style.display = name === 'garmin' ? '' : 'none';
} }
function openModal() { function openModal() {
@@ -531,6 +596,7 @@ try {
backFile.addEventListener('click', () => showView('choose')); backFile.addEventListener('click', () => showView('choose'));
backStrava.addEventListener('click', () => showView('choose')); backStrava.addEventListener('click', () => showView('choose'));
backZip.addEventListener('click', () => showView('choose')); backZip.addEventListener('click', () => showView('choose'));
backGarmin.addEventListener('click', () => showView('choose'));
// ── file upload ─────────────────────────────────────────────────────── // ── file upload ───────────────────────────────────────────────────────
drop.addEventListener('click', () => input.click()); drop.addEventListener('click', () => input.click());
@@ -845,6 +911,137 @@ try {
doZipUpload(e.dataTransfer?.files?.[0]); 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) // Handle ?strava= param set by the callback redirect (popup scenario)
const sp = new URLSearchParams(window.location.search); const sp = new URLSearchParams(window.location.search);
if (sp.has('strava')) { if (sp.has('strava')) {