feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user