feat: Garmin gear sync — registry + per-activity gear on sync and backfill

- garmin_sync_iter: sync gear registry from Garmin on every sync run and
  resolve gear for each newly imported activity via get_activity_gear()
- POST /api/garmin/import-gear: one-time backfill that matches Garmin gear
  activities to existing local activities by UTC timestamp (±60 s)
This commit is contained in:
Davide Scaini
2026-05-24 13:03:34 +02:00
parent b23b3de1bb
commit 49feef66c5
2 changed files with 212 additions and 8 deletions
+52 -3
View File
@@ -22,9 +22,9 @@ from __future__ import annotations
import io import io
import json import json
import zipfile import zipfile
from datetime import datetime, timedelta, timezone from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Generator
_SYNC_FILE = "garmin_sync.json" _SYNC_FILE = "garmin_sync.json"
@@ -73,9 +73,13 @@ def garmin_sync_iter(
data_dir: Root data directory (used for encryption key lookup). data_dir: Root data directory (used for encryption key lookup).
user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.). user_dir: Per-user directory (contains activities/, garmin_creds.json, etc.).
""" """
import uuid as _uuid
from bincio.extract.garmin_api import GarminError, get_client from bincio.extract.garmin_api import GarminError, get_client
from bincio.extract.ingest import ingest_parsed from bincio.extract.ingest import ingest_parsed
from bincio.extract.parsers.fit import FitParser from bincio.extract.parsers.fit import FitParser
from bincio.serve.routers.gear import _load as _gear_load
from bincio.serve.routers.gear import _save as _gear_save
# ── Login ────────────────────────────────────────────────────────────────── # ── Login ──────────────────────────────────────────────────────────────────
try: try:
@@ -86,6 +90,41 @@ def garmin_sync_iter(
yield {"type": "fetching"} yield {"type": "fetching"}
# ── Sync gear registry ─────────────────────────────────────────────────────
_garmin_uuid_to_name: dict[str, str] = {}
try:
prof = client.connectapi("/userprofile-service/socialProfile")
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
if profile_id:
garmin_gear = client.get_gear(profile_id)
if isinstance(garmin_gear, list):
registry = _gear_load(user_dir)
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
for g in garmin_gear:
guuid = g.get("uuid") or ""
name = (g.get("customMakeModel") or g.get("displayName") or
f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip())
if not name or not guuid:
continue
_garmin_uuid_to_name[guuid] = name
if guuid not in known:
gear_type = g.get("gearTypeName", "").lower()
if gear_type not in ("bike", "shoes", "skis"):
gear_type = "other"
retired = g.get("gearStatusName") == "retired"
registry.append({"id": str(_uuid.uuid4()), "name": name,
"type": gear_type, "retired": retired,
"garmin_id": guuid})
known.add(guuid)
else:
# Update name in case it changed
for item in registry:
if item.get("garmin_id") == guuid:
item["name"] = name
_gear_save(user_dir, registry)
except Exception:
pass # gear sync is best-effort; don't abort activity sync
# ── Determine date range ─────────────────────────────────────────────────── # ── Determine date range ───────────────────────────────────────────────────
state = _load_sync_state(user_dir) state = _load_sync_state(user_dir)
last = state.get("last_sync_at") last = state.get("last_sync_at")
@@ -144,6 +183,16 @@ def garmin_sync_iter(
except Exception as exc: except Exception as exc:
raise RuntimeError(f"FIT parse error: {exc}") from exc raise RuntimeError(f"FIT parse error: {exc}") from exc
# Resolve gear for this activity
if garmin_id and _garmin_uuid_to_name:
try:
act_gear = client.get_activity_gear(garmin_id)
if isinstance(act_gear, list) and act_gear:
guuid = act_gear[0].get("uuid") or ""
parsed.gear = _garmin_uuid_to_name.get(guuid) or None
except Exception:
pass
# Ingest — raises FileExistsError if already present (dedup) # Ingest — raises FileExistsError if already present (dedup)
ingest_parsed(parsed, user_dir) ingest_parsed(parsed, user_dir)
imported += 1 imported += 1
@@ -173,7 +222,7 @@ def garmin_sync_iter(
} }
# ── Persist sync state ───────────────────────────────────────────────────── # ── Persist sync state ─────────────────────────────────────────────────────
state["last_sync_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%d") state["last_sync_at"] = datetime.now(UTC).strftime("%Y-%m-%d")
state["total_imported"] = state.get("total_imported", 0) + imported state["total_imported"] = state.get("total_imported", 0) + imported
_save_sync_state(user_dir, state) _save_sync_state(user_dir, state)
+160 -5
View File
@@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Optional from datetime import UTC
from fastapi import APIRouter, Cookie, HTTPException, Request from fastapi import APIRouter, Cookie, HTTPException, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
@@ -41,7 +41,7 @@ def _garmin_user_message(exc: Exception) -> str:
@router.get("/api/garmin/status") @router.get("/api/garmin/status")
async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: async def garmin_status(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Return whether Garmin credentials are stored for the current user.""" """Return whether Garmin credentials are stored for the current user."""
user = deps._require_user(bincio_session) user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle dd = deps._get_data_dir() / user.handle
@@ -58,7 +58,7 @@ async def garmin_status(bincio_session: Optional[str] = Cookie(default=None)) ->
@router.post("/api/garmin/connect") @router.post("/api/garmin/connect")
async def garmin_connect( async def garmin_connect(
request: Request, request: Request,
bincio_session: Optional[str] = Cookie(default=None), bincio_session: str | None = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
"""Test Garmin login with the supplied credentials and save them on success.""" """Test Garmin login with the supplied credentials and save them on success."""
user = deps._require_user(bincio_session) user = deps._require_user(bincio_session)
@@ -79,7 +79,7 @@ async def garmin_connect(
@router.post("/api/garmin/disconnect") @router.post("/api/garmin/disconnect")
async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: async def garmin_disconnect(bincio_session: str | None = Cookie(default=None)) -> JSONResponse:
"""Remove stored Garmin credentials and session for the current user.""" """Remove stored Garmin credentials and session for the current user."""
user = deps._require_user(bincio_session) user = deps._require_user(bincio_session)
dd = deps._get_data_dir() / user.handle dd = deps._get_data_dir() / user.handle
@@ -89,7 +89,7 @@ async def garmin_disconnect(bincio_session: Optional[str] = Cookie(default=None)
@router.get("/api/garmin/sync/stream") @router.get("/api/garmin/sync/stream")
async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: async def garmin_sync_stream(bincio_session: str | None = Cookie(default=None)) -> StreamingResponse:
"""SSE endpoint — streams per-activity Garmin sync progress.""" """SSE endpoint — streams per-activity Garmin sync progress."""
user = deps._require_user(bincio_session) user = deps._require_user(bincio_session)
data_dir = deps._get_data_dir() data_dir = deps._get_data_dir()
@@ -117,3 +117,158 @@ async def garmin_sync_stream(bincio_session: Optional[str] = Cookie(default=None
media_type="text/event-stream", media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, 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."""
import contextlib
import re
import uuid
import yaml
from bincio.extract.garmin_api import GarminError, get_client, has_credentials
from bincio.render.merge import merge_one
from bincio.serve.routers.gear import _load as _gear_load
from bincio.serve.routers.gear import _save as _gear_save
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:
client = get_client(data_dir, user_dir)
except GarminError as e:
raise HTTPException(502, str(e))
# Fetch gear list
try:
prof = client.connectapi("/userprofile-service/socialProfile")
profile_id = prof.get("profileId") if isinstance(prof, dict) else None
if not profile_id:
raise HTTPException(502, "Could not read Garmin profile ID")
garmin_gear = client.get_gear(profile_id)
except GarminError as e:
raise HTTPException(502, str(e))
if not isinstance(garmin_gear, list) or not garmin_gear:
return JSONResponse({"ok": True, "gear_added": 0, "activities_updated": 0})
# Build / update gear registry
registry = _gear_load(user_dir)
known = {g.get("garmin_id") for g in registry if g.get("garmin_id")}
uuid_to_name: dict[str, str] = {}
gear_added = 0
for g in garmin_gear:
guuid = g.get("uuid") or ""
name = (g.get("customMakeModel") or g.get("displayName") or
f"{g.get('gearMakeName','')} {g.get('gearModelName','')}".strip())
if not name or not guuid:
continue
uuid_to_name[guuid] = name
if guuid not in known:
gear_type = g.get("gearTypeName", "").lower()
if gear_type not in ("bike", "shoes", "skis"):
gear_type = "other"
retired = g.get("gearStatusName") == "retired"
registry.append({"id": str(uuid.uuid4()), "name": name,
"type": gear_type, "retired": retired, "garmin_id": guuid})
known.add(guuid)
gear_added += 1
else:
for item in registry:
if item.get("garmin_id") == guuid:
item["name"] = name
_gear_save(user_dir, registry)
# Build timestamp → activity_id map from the user's index shards
from datetime import datetime
ts_to_id: dict[int, str] = {}
merged_dir = user_dir / "_merged"
shard_dirs = [merged_dir] if merged_dir.exists() else [user_dir]
for shard_dir in shard_dirs:
for shard_path in sorted(shard_dir.glob("index*.json")):
try:
idx = json.loads(shard_path.read_text(encoding="utf-8"))
for a in idx.get("activities", []):
started = a.get("started_at") or ""
if started and a.get("id"):
dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
ts_to_id[int(dt.astimezone(UTC).timestamp())] = a["id"]
except (OSError, json.JSONDecodeError, KeyError):
continue
# For each gear, fetch its activities and match by timestamp
edits_dir = user_dir / "edits"
edits_dir.mkdir(exist_ok=True)
activities_updated = 0
for guuid, gear_name in uuid_to_name.items():
try:
gear_acts = client.get_gear_activities(guuid, limit=10000)
except Exception:
continue
if not isinstance(gear_acts, list):
continue
for ga in gear_acts:
gmt = ga.get("startTimeGMT") or ""
if not gmt:
continue
try:
from datetime import datetime
dt = datetime.strptime(gmt, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
ts = int(dt.timestamp())
except ValueError:
continue
# Match within ±60 s
act_id = None
for delta in range(0, 61):
act_id = ts_to_id.get(ts + delta) or ts_to_id.get(ts - delta)
if act_id:
break
if not act_id:
continue
# Skip if activity already has gear set
act_json = user_dir / "activities" / f"{act_id}.json"
if act_json.exists():
try:
if json.loads(act_json.read_text(encoding="utf-8")).get("gear"):
continue
except (OSError, json.JSONDecodeError):
pass
sidecar = edits_dir / f"{act_id}.md"
fm, body = {}, ""
if sidecar.exists():
try:
text = sidecar.read_text(encoding="utf-8")
parts = re.split(r"^---[ \t]*$", text, maxsplit=2, flags=re.MULTILINE)
if len(parts) >= 3:
fm = yaml.safe_load(parts[1]) or {}
body = parts[2].strip()
except Exception:
pass
if fm.get("gear"):
continue
fm["gear"] = gear_name
fm_text = yaml.safe_dump(fm, default_flow_style=False, allow_unicode=True).strip()
content = f"---\n{fm_text}\n---\n"
if body:
content += f"\n{body}\n"
sidecar.write_text(content, encoding="utf-8")
with contextlib.suppress(Exception):
merge_one(user_dir, act_id)
activities_updated += 1
tasks._trigger_rebuild(user.handle)
return JSONResponse({"ok": True, "gear_added": gear_added, "activities_updated": activities_updated})