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 json
import zipfile
from datetime import datetime, timedelta, timezone
from collections.abc import Generator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Generator
_SYNC_FILE = "garmin_sync.json"
@@ -73,9 +73,13 @@ def garmin_sync_iter(
data_dir: Root data directory (used for encryption key lookup).
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.ingest import ingest_parsed
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 ──────────────────────────────────────────────────────────────────
try:
@@ -86,6 +90,41 @@ def garmin_sync_iter(
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 ───────────────────────────────────────────────────
state = _load_sync_state(user_dir)
last = state.get("last_sync_at")
@@ -144,6 +183,16 @@ def garmin_sync_iter(
except Exception as 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_parsed(parsed, user_dir)
imported += 1
@@ -173,7 +222,7 @@ def garmin_sync_iter(
}
# ── 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
_save_sync_state(user_dir, state)