import hashlib import json import math from datetime import UTC, datetime from pathlib import Path from garmin_sync.service import SyncError, complete, decode_fit from .extensions import db from .models import Activity def number(value, default=0): if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value): return float(value) return default def instant(value): if isinstance(value, datetime): return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) parsed = datetime.fromisoformat(str(value)) return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC) def activity_from_fit(owner_id, source_key, documents, metadata=None): metadata = metadata or {} if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)): return None sessions = [s for doc in documents for s in doc.get("session", [])] if not sessions: raise SyncError("This FIT file has no completed activity session.") first = sessions[0] sport = str(first.get("sport") or "activity") started = instant(first.get("start_time") or metadata.get("startTimeGMT")) records = [r for doc in documents for r in doc.get("record", [])] samples = [] for record in records: lat, lng = record.get("position_lat"), record.get("position_long") point = None if lat is not None and lng is not None: lat = number(lat, None) lng = number(lng, None) if lat is not None and lng is not None: lat, lng = lat * 180 / 2**31, lng * 180 / 2**31 if -90 <= lat <= 90 and -180 <= lng <= 180: point = [lat, lng] samples.append( { "point": point, "distance": number(record.get("distance"), None), "heart_rate": number(record.get("heart_rate"), None), "speed": number(record.get("enhanced_speed", record.get("speed")), None), "elevation": number(record.get("enhanced_altitude", record.get("altitude")), None), } ) laps = [] for document in documents: for lap in document.get("lap", []): distance = number(lap.get("total_distance")) duration = number(lap.get("total_timer_time")) laps.append( { "distance": distance, "duration": duration, "heart_rate": number(lap.get("avg_heart_rate"), None), "pace": duration * 1000 / distance if distance > 0 else None, } ) row = Activity( owner_id=owner_id, source_key=source_key, title=str(metadata.get("activityName") or f"{started:%A} {sport}")[:160], sport=sport, started_at=started, distance_m=sum(number(s.get("total_distance")) for s in sessions), duration_s=sum(number(s.get("total_timer_time")) for s in sessions), elevation_m=sum(number(s.get("total_ascent")) for s in sessions), avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None), samples=samples, laps=laps, ) db.session.add(row) return row def import_cache(user, data_root): if not user.local_account_key: return 0 directory = Path(data_root) / user.local_account_key count = 0 for manifest in sorted(directory.glob("*/manifest.json")): if not complete(manifest.parent): continue metadata = json.loads((manifest.parent / "activity.json").read_text()) docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))] source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}" if activity_from_fit(user.id, source_key, docs, metadata): count += 1 return count def import_upload(user, payload): digest = hashlib.sha256(payload).hexdigest() document = decode_fit(payload) # Normalize datetime values before SQLAlchemy's JSON serializer. from garmin_sync.service import json_default document = json.loads(json.dumps(document, default=json_default)) return activity_from_fit(user.id, f"file:{digest}", [document]) def activity_data(activity, owner_view=False): """Call only after access checks. Owners and accepted friends see the full route.""" segments, segment = [], [] for sample in activity.samples: point = sample.get("point") if point: segment.append(point) elif segment: segments.append(segment) segment = [] if segment: segments.append(segment) step = max(1, math.ceil(len(activity.samples) / 750)) chart = [ {k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")} for sample in activity.samples[::step] ] return {"segments": segments, "chart": chart}