131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
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}
|