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, refresh=False): metadata = metadata or {} existing = db.session.scalar( db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key) ) if existing and not refresh: 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), "time": instant(record["timestamp"]).timestamp() if record.get("timestamp") else None, "cadence": ( (number(record.get("cadence")) + number(record.get("fractional_cadence"))) * 2 if sport == "running" and record.get("cadence") is not None else None ), "power": number(record.get("power"), None), "stride_length": ( number(record.get("step_length")) / 1000 if record.get("step_length") is not None else None ), "vertical_oscillation": ( number(record.get("vertical_oscillation")) / 10 if record.get("vertical_oscillation") is not None else None ), "ground_contact": number(record.get("stance_time"), 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, } ) metrics = { key: number(first.get(key), None) for key in ( "total_elapsed_time", "total_moving_time", "max_heart_rate", "avg_power", "max_power", "total_calories", "total_descent", "total_training_effect", "total_anaerobic_training_effect", "avg_vertical_oscillation", "avg_stance_time", "avg_step_length", "avg_running_cadence", ) } if existing: existing.samples, existing.laps, existing.metrics = samples, laps, metrics return existing 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), metrics=metrics, samples=samples, laps=laps, ) db.session.add(row) return row def import_cache(user, data_root, refresh=False): 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, refresh=refresh): 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.""" from .analytics import analyse analysis = analyse(activity) segments, segment = [], [] route_segments, route = [], [] for sample in analysis["samples"]: point = sample.get("point") if point: segment.append(point) route.append(sample) elif segment: segments.append(segment) segment = [] route_segments.append(route) route = [] if segment: segments.append(segment) route_segments.append(route) step = max(1, math.ceil(len(activity.samples) / 750)) chart = [ { k: sample.get(k) for k in ( "distance", "heart_rate", "speed", "elevation", "grade", "gap", "pace", "cadence", "power", "stride_length", "vertical_oscillation", "ground_contact", ) } for sample in analysis["samples"][::step] ] return {"segments": segments, "route_segments": route_segments, "chart": chart}