200 lines
7.2 KiB
Python
200 lines
7.2 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, 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}
|