"""Garmin Connect endpoints (/api/garmin/*).""" from __future__ import annotations import json from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from bincio.serve import deps, tasks router = APIRouter() 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 @router.get("/api/garmin/status") async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: """Return whether Garmin credentials are stored for the current user.""" user = deps._require_user(bincio_session) dd = deps._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}) @router.post("/api/garmin/connect") async def garmin_connect( request: Request, bincio_session: str | None = Cookie(default=None), ) -> JSONResponse: """Test Garmin login with the supplied credentials and save them on success.""" user = deps._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 = deps._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}) @router.post("/api/garmin/disconnect") async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: """Remove stored Garmin credentials and session for the current user.""" user = deps._require_user(bincio_session) dd = deps._get_data_dir() / user.handle from bincio.extract.garmin_api import delete_credentials delete_credentials(dd) return JSONResponse({"ok": True}) @router.get("/api/garmin/sync/stream") async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse: """SSE endpoint — streams per-activity Garmin sync progress.""" user = deps._require_user(bincio_session) data_dir = deps._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": tasks._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"}, ) @router.post("/api/garmin/import-gear") async def garmin_import_gear(bincio_session: str | None = Cookie(default=None)) -> JSONResponse: """One-time backfill: fetch gear registry from Garmin and match to existing activities by timestamp.""" from bincio.extract.garmin_api import GarminError, has_credentials from bincio.extract.garmin_sync import import_garmin_gear user = deps._require_user(bincio_session) data_dir = deps._get_data_dir() user_dir = data_dir / user.handle if not has_credentials(user_dir): raise HTTPException(400, "No Garmin credentials stored — connect first") try: result = import_garmin_gear(data_dir, user_dir) except GarminError as exc: raise HTTPException(502, _garmin_user_message(exc)) tasks._trigger_rebuild(user.handle) return JSONResponse({"ok": True, **result})