Files
Stride/app/services.py
T

203 lines
7.3 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
from .records import persist_efforts
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
persist_efforts(existing)
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,
)
persist_efforts(row)
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}