Multi-user via PocketID: account linking, group gating, admin user management

PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the
data layer was already fully user-scoped. This adds the missing pieces for
running real multi-user:

- auth.py callback: link by email to an existing un-linked account (so the
  admin keeps their data when first signing in by passkey), collision-safe
  username generation, and request the `groups` scope.
- Group gating: optional pocketid_allowed_group (admin-config or
  POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the
  callback and redirected to /login?auth_error=not_authorized.
- New admin users API (app/api/users.py): list users, promote/demote admin
  (guards against demoting/locking out the last admin or yourself), and delete
  a user with ordered bulk deletes of all their data + on-disk files.
- ProfilePage: allowed-group field; LoginPage: rejected-login message;
  Layout: admin-only Users nav; new UsersPage.

Resync milevault_export to current source (it had drifted many features behind
— missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own
CI) and add POCKETID_ALLOWED_GROUP to .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:19:55 +01:00
parent bc4d68da07
commit 0e4bc7b444
46 changed files with 3282 additions and 588 deletions
@@ -1,30 +1,30 @@
"""
FIT and GPX file parser using:
- Official Garmin FIT Python SDK (garmin-fit-sdk) for .fit files
- gpxpy for .gpx files
The official SDK correctly handles scale/offset, component expansion,
semicircle-to-degree conversion, and HR message merging.
FIT and GPX file parser.
Parses FIT files directly using the Garmin SDK but applies manual
scale conversion for fields where the SDK doesn't auto-convert.
"""
import math
from pathlib import Path
from datetime import datetime, timezone, timedelta
import struct
from datetime import datetime, timezone
from typing import Optional
import gpxpy
import polyline as polyline_lib
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600
SEMICIRCLES_TO_DEG = 180.0 / (2 ** 31)
def haversine_distance(lat1, lon1, lat2, lon2) -> float:
"""Distance in metres between two GPS points."""
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
return 2 * R * math.asin(math.sqrt(a))
def _semicircles_to_deg(val):
if val is None:
return None
try:
result = float(val) * SEMICIRCLES_TO_DEG
if -90 <= result <= 90 or -180 <= result <= 180:
return result
except (TypeError, ValueError):
pass
return None
def _safe_float(val) -> Optional[float]:
@@ -34,7 +34,17 @@ def _safe_float(val) -> Optional[float]:
return None
def _bounding_box(coords: list) -> Optional[dict]:
def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
"""Reject the FIT invalid sentinel (0xFFFF/1000 = 65.535 m/s) and fall back to dist/dur."""
fv = _safe_float(val)
if fv is None or fv >= 65.0:
if dist_m and dur_s and float(dur_s) > 0:
return float(dist_m) / float(dur_s)
return None
return fv
def _bounding_box(coords):
if not coords:
return None
lats = [c[0] for c in coords]
@@ -43,18 +53,35 @@ def _bounding_box(coords: list) -> Optional[dict]:
"min_lon": min(lons), "max_lon": max(lons)}
def parse_fit_file(filepath: str) -> dict:
"""Parse a Garmin .fit activity file using the official Garmin SDK."""
from garmin_fit_sdk import Decoder, Stream
def _to_dt(val) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
try:
return datetime.fromtimestamp(int(val) + FIT_EPOCH_S, tz=timezone.utc)
except (OSError, OverflowError, ValueError):
return None
return None
session = {}
def _is_valid_lat(v):
return v is not None and -90 <= v <= 90
def _is_valid_lon(v):
return v is not None and -180 <= v <= 180
def parse_fit_file(filepath: str) -> dict:
session_data = {}
records = []
laps = []
def listener(mesg_num: int, msg: dict):
nonlocal session
if mesg_num == 18: # session
session = msg
session_data.update(msg)
elif mesg_num == 20: # record
records.append(msg)
elif mesg_num == 19: # lap
@@ -73,68 +100,113 @@ def parse_fit_file(filepath: str) -> dict:
mesg_listener=listener,
)
# Map sport type
sport = str(session.get("sport", "generic")).lower()
sport_map = {
"running": "running", "cycling": "cycling", "swimming": "swimming",
"hiking": "hiking", "walking": "walking", "generic": "other",
"open_water_swimming": "swimming", "trail_running": "running",
"e_biking": "cycling",
}
sport_type = sport_map.get(sport, sport)
# The SDK may return field names in camelCase or snake_case depending on version.
# Try both. Also handle raw timestamp integers for start_time.
def get(d, *keys):
for k in keys:
v = d.get(k)
if v is not None:
return v
return None
start_time = session.get("start_time")
if isinstance(start_time, datetime) and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
sport_raw = str(get(session_data, "sport", "Sport") or "generic").lower()
sport_map = {
"running": "running", "cycling": "cycling",
"hiking": "hiking", "walking": "walking",
"generic": "other", "trail_running": "running",
"e_biking": "cycling", "open_water_swimming": "other",
}
sport_type = sport_map.get(sport_raw, sport_raw)
# start_time — SDK may return datetime or raw int
start_time_raw = get(session_data, "startTime", "start_time")
start_time = _to_dt(start_time_raw)
# Position fields — the SDK may or may not convert semicircles.
# Check if values look like semicircles (>= 90 for lat) and convert if so.
def get_lat(d):
v = get(d, "positionLat", "position_lat")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
# If absolute value > 90, it's semicircles
if abs(fv) > 90:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lat(fv) else None
def get_lon(d):
v = get(d, "positionLong", "position_long")
if v is None:
return None
fv = _safe_float(v)
if fv is None:
return None
if abs(fv) > 180:
fv = fv * SEMICIRCLES_TO_DEG
return fv if _is_valid_lon(fv) else None
# Build GPS track
coords = [
(r["position_lat"], r["position_long"])
for r in records
if r.get("position_lat") is not None and r.get("position_long") is not None
]
coords = []
for r in records:
lat = get_lat(r)
lon = get_lon(r)
if lat is not None and lon is not None:
coords.append((lat, lon))
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Normalize data points
normalized_points = []
for r in records:
ts = r.get("timestamp")
if isinstance(ts, datetime) and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
ts = _to_dt(get(r, "timestamp"))
lat = get_lat(r)
lon = get_lon(r)
altitude = get(r, "altitude", "enhancedAltitude", "enhanced_altitude")
hr = get(r, "heartRate", "heart_rate")
cadence = get(r, "cadence")
speed = get(r, "speed", "enhancedSpeed", "enhanced_speed")
power = get(r, "power")
temp = get(r, "temperature")
distance = get(r, "distance")
normalized_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": r.get("position_lat"),
"longitude": r.get("position_long"),
"altitude_m": r.get("altitude") or r.get("enhanced_altitude"),
"heart_rate": r.get("heart_rate"),
"cadence": r.get("cadence") or r.get("fractional_cadence"),
"speed_ms": r.get("speed") or r.get("enhanced_speed"),
"power": r.get("power"),
"temperature_c": r.get("temperature"),
"distance_m": r.get("distance"),
"latitude": _safe_float(lat),
"longitude": _safe_float(lon),
"altitude_m": _safe_float(altitude),
"heart_rate": _safe_float(hr),
"cadence": _safe_float(cadence),
"speed_ms": _safe_float(speed),
"power": _safe_float(power),
"temperature_c": _safe_float(temp),
"distance_m": _safe_float(distance),
})
# Normalize laps
normalized_laps = []
for i, lap in enumerate(laps):
ls = lap.get("start_time")
if isinstance(ls, datetime) and ls.tzinfo is None:
ls = ls.replace(tzinfo=timezone.utc)
ls = _to_dt(get(lap, "startTime", "start_time"))
lap_dist = _safe_float(get(lap, "totalDistance", "total_distance"))
lap_dur = _safe_float(get(lap, "totalElapsedTime", "total_elapsed_time"))
normalized_laps.append({
"lap_number": i + 1,
"start_time": ls.isoformat() if ls else None,
"duration_s": _safe_float(lap.get("total_elapsed_time")),
"distance_m": _safe_float(lap.get("total_distance")),
"avg_heart_rate": _safe_float(lap.get("avg_heart_rate")),
"avg_cadence": _safe_float(lap.get("avg_cadence")),
"avg_speed_ms": _safe_float(lap.get("avg_speed") or lap.get("enhanced_avg_speed")),
"avg_power": _safe_float(lap.get("avg_power")),
"duration_s": lap_dur,
"distance_m": lap_dist,
"avg_heart_rate": _safe_float(get(lap, "avgHeartRate", "avg_heart_rate")),
"avg_cadence": _safe_float(get(lap, "avgCadence", "avg_cadence")),
"avg_speed_ms": _sanitize_speed(
get(lap, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=lap_dist, dur_s=lap_dur,
),
"avg_power": _safe_float(get(lap, "avgPower", "avg_power")),
})
# Build activity name
name = session.get("sport", "Activity").title()
name = sport_type.title()
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
@@ -142,21 +214,28 @@ def parse_fit_file(filepath: str) -> dict:
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(session.get("total_distance")),
"duration_s": _safe_float(session.get("total_elapsed_time")),
"elevation_gain_m": _safe_float(session.get("total_ascent")),
"elevation_loss_m": _safe_float(session.get("total_descent")),
"avg_heart_rate": _safe_float(session.get("avg_heart_rate")),
"max_heart_rate": _safe_float(session.get("max_heart_rate")),
"avg_cadence": _safe_float(session.get("avg_cadence")),
"avg_power": _safe_float(session.get("avg_power")),
"normalized_power": _safe_float(session.get("normalized_power")),
"avg_speed_ms": _safe_float(session.get("avg_speed") or session.get("enhanced_avg_speed")),
"max_speed_ms": _safe_float(session.get("max_speed") or session.get("enhanced_max_speed")),
"avg_temperature_c": _safe_float(session.get("avg_temperature")),
"calories": _safe_float(session.get("total_calories")),
"training_stress_score": _safe_float(session.get("training_stress_score")),
"vo2max_estimate": _safe_float(session.get("total_training_effect")),
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
"max_heart_rate": _safe_float(get(session_data, "maxHeartRate", "max_heart_rate")),
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
"calories": _safe_float(get(session_data, "totalCalories", "total_calories")),
"training_stress_score": _safe_float(get(session_data, "trainingStressScore",
"training_stress_score")),
"vo2max_estimate": _safe_float(get(session_data, "totalTrainingEffect",
"total_training_effect")),
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
@@ -166,7 +245,6 @@ def parse_fit_file(filepath: str) -> dict:
def parse_gpx_file(filepath: str) -> dict:
"""Parse a GPX file."""
with open(filepath) as f:
gpx = gpxpy.parse(f)
@@ -180,7 +258,6 @@ def parse_gpx_file(filepath: str) -> dict:
ts = pt.time
if ts and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
extensions = {}
if pt.extensions:
for ext in pt.extensions:
@@ -190,11 +267,9 @@ def parse_gpx_file(filepath: str) -> dict:
extensions[tag] = float(child.text)
except (ValueError, TypeError):
pass
data_points.append({
"timestamp": ts.isoformat() if ts else None,
"latitude": pt.latitude,
"longitude": pt.longitude,
"latitude": pt.latitude, "longitude": pt.longitude,
"altitude_m": pt.elevation,
"heart_rate": extensions.get("hr"),
"cadence": extensions.get("cad"),
@@ -204,91 +279,61 @@ def parse_gpx_file(filepath: str) -> dict:
"distance_m": None,
})
coords = [(p["latitude"], p["longitude"]) for p in data_points
if p["latitude"] and p["longitude"]]
coords = [(p["latitude"], p["longitude"]) for p in data_points if p["latitude"] and p["longitude"]]
encoded_polyline = polyline_lib.encode(coords) if coords else None
bounding_box = _bounding_box(coords)
# Add cumulative distance
total_dist = 0.0
prev = None
for p in data_points:
if p["latitude"] and p["longitude"]:
if prev:
total_dist += haversine_distance(prev[0], prev[1], p["latitude"], p["longitude"])
R = 6371000
phi1, phi2 = math.radians(prev[0]), math.radians(p["latitude"])
dphi = math.radians(p["latitude"] - prev[0])
dlam = math.radians(p["longitude"] - prev[1])
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlam/2)**2
total_dist += 2 * R * math.asin(math.sqrt(a))
prev = (p["latitude"], p["longitude"])
p["distance_m"] = total_dist
# Elevation gain/loss
uphill, downhill = 0.0, 0.0
alts = [p["altitude_m"] for p in data_points if p["altitude_m"]]
for i in range(1, len(alts)):
diff = alts[i] - alts[i-1]
if diff > 0:
uphill += diff
else:
downhill += abs(diff)
if diff > 0: uphill += diff
else: downhill += abs(diff)
hrs = [p["heart_rate"] for p in data_points if p["heart_rate"]]
start_time_str = data_points[0]["timestamp"] if data_points else None
start_dt = datetime.fromisoformat(start_time_str) if start_time_str else None
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = "running"
if track.type:
sport = track.type.lower()
sport = track.type.lower() if track.type else "running"
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport,
"start_time": start_time_str,
"distance_m": total_dist,
"duration_s": duration,
"elevation_gain_m": uphill,
"elevation_loss_m": downhill,
"sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration,
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None,
"avg_power": None,
"normalized_power": None,
"avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"max_speed_ms": None,
"avg_temperature_c": None,
"calories": None,
"training_stress_score": None,
"vo2max_estimate": None,
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "gpx",
"data_points": data_points,
"laps": [],
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx", "data_points": data_points, "laps": [],
}
def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
"""
Calculate % time in each HR zone using the user's configured max HR.
Zones follow the standard 5-zone model as % of max HR:
Z1 Recovery: < 60%
Z2 Base: 60 - 70%
Z3 Tempo: 70 - 80%
Z4 Threshold: 80 - 90%
Z5 Max: > 90%
user_max_hr should be the user's actual physiological max HR, NOT the
highest HR recorded in this activity. Using activity max shifts all zones
upward and makes easy runs look harder than they are.
"""
if not user_max_hr or user_max_hr < 100:
return {}
zone_bounds = [0.0, 0.60, 0.70, 0.80, 0.90, 1.01]
zone_keys = ["z1", "z2", "z3", "z4", "z5"]
zones = {k: 0 for k in zone_keys}
total = 0
for p in data_points:
hr = p.get("heart_rate")
if not hr or hr < 20:
@@ -300,8 +345,7 @@ def calculate_hr_zones(data_points: list, user_max_hr: float) -> dict:
zones[key] += 1
break
else:
zones["z5"] += 1 # anything above 90% goes to z5
zones["z5"] += 1
if total:
return {k: round(v / total * 100, 1) for k, v in zones.items()}
return {}
return {}
@@ -524,7 +524,7 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
if stats:
_set(row, "resting_hr", stats.get("restingHeartRate"))
_set(row, "avg_hr_day", stats.get("averageHeartRate"))
# averageHeartRate is absent from get_stats; avg_hr_day is computed below from intraday HR
_set(row, "max_hr_day", stats.get("maxHeartRate"))
_set(row, "steps", stats.get("totalSteps"))
_set(row, "floors_climbed", stats.get("floorsAscended"))
@@ -63,11 +63,21 @@ def routes_are_similar(
bb1: Optional[dict],
bb2: Optional[dict],
dtw_threshold_m: float = 80.0,
dist1: Optional[float] = None,
dist2: Optional[float] = None,
) -> bool:
"""
Returns True if two activities are on sufficiently similar routes.
First does a cheap bounding box check, then DTW on downsampled tracks.
When dist1/dist2 are provided:
- Rejects if distance differs by more than 2.5%
- Uses 3% of route distance as the DTW threshold (capped at 300m)
"""
if dist1 and dist2 and dist1 > 0 and dist2 > 0:
if abs(dist1 - dist2) / max(dist1, dist2) > 0.025:
return False
dtw_threshold_m = min(max(dist1, dist2) * 0.03, 300.0)
if bb1 and bb2:
if not bounding_boxes_overlap(bb1, bb2):
return False
@@ -164,6 +174,154 @@ def find_best_split_time(
return best
def _bearing(p1: tuple, p2: tuple) -> float:
"""Compass bearing in degrees (0-360) from p1 to p2."""
lat1, lon1 = math.radians(p1[0]), math.radians(p1[1])
lat2, lon2 = math.radians(p2[0]), math.radians(p2[1])
dlon = lon2 - lon1
x = math.sin(dlon) * math.cos(lat2)
y = math.cos(lat1) * math.sin(lat2) - math.sin(lat1) * math.cos(lat2) * math.cos(dlon)
return math.degrees(math.atan2(x, y)) % 360
def generate_1km_segments(encoded_polyline: str, total_dist_m: float) -> list[tuple[str, float, float]]:
"""Generate 1-km splits along a route. Returns list of (name, start_m, end_m)."""
if not encoded_polyline:
return []
km_count = int(total_dist_m / 1000)
segments = []
for i in range(km_count):
segments.append((f"km {i + 1}", float(i * 1000), float((i + 1) * 1000)))
remainder = total_dist_m - km_count * 1000
if remainder >= 200:
segments.append((f"km {km_count + 1}", float(km_count * 1000), total_dist_m))
return segments
def generate_turn_segments(
encoded_polyline: str,
turn_angle_deg: float = 45.0,
) -> list[tuple[str, float, float]]:
"""Detect sharp turns in a route polyline. Returns list of (name, start_m, end_m)."""
coords = decode_polyline_to_coords(encoded_polyline)
if len(coords) < 3:
return []
cum_dists = [0.0]
for i in range(1, len(coords)):
cum_dists.append(cum_dists[-1] + haversine_m(coords[i - 1], coords[i]))
total = cum_dists[-1]
HALF_WINDOW = 100.0 # metres either side of candidate turn point
turn_centers: list[float] = []
for i in range(1, len(coords) - 1):
# Find index ~HALF_WINDOW before and after
start_i = i
while start_i > 0 and cum_dists[i] - cum_dists[start_i] < HALF_WINDOW:
start_i -= 1
end_i = i
while end_i < len(coords) - 1 and cum_dists[end_i] - cum_dists[i] < HALF_WINDOW:
end_i += 1
if start_i == i or end_i == i:
continue
b1 = _bearing(coords[start_i], coords[i])
b2 = _bearing(coords[i], coords[end_i])
diff = abs(b2 - b1) % 360
if diff > 180:
diff = 360 - diff
if diff >= turn_angle_deg:
turn_centers.append(cum_dists[i])
if not turn_centers:
return []
# Cluster turns within 150 m of each other → one segment per cluster
clusters: list[list[float]] = [[turn_centers[0]]]
for d in turn_centers[1:]:
if d - clusters[-1][-1] < 150:
clusters[-1].append(d)
else:
clusters.append([d])
segments = []
for cluster in clusters:
center = sum(cluster) / len(cluster)
start = max(0.0, center - HALF_WINDOW)
end = min(total, center + HALF_WINDOW)
segments.append((f"Turn at {center / 1000:.1f} km", start, end))
return segments
def generate_hill_segments(
data_points: list[dict],
gradient_pct: float = 5.0,
) -> list[tuple[str, float, float]]:
"""
Detect uphill sections using activity data points (with altitude_m + distance_m).
Returns list of (name, start_m, end_m).
"""
pts = [
(p["distance_m"], p["altitude_m"])
for p in data_points
if p.get("distance_m") is not None and p.get("altitude_m") is not None
]
if len(pts) < 10:
return []
pts.sort(key=lambda x: x[0])
dists = [p[0] for p in pts]
alts = [p[1] for p in pts]
# Smooth altitude with a sliding window to reduce GPS noise
SMOOTH = 10
smooth_alts = []
for i in range(len(alts)):
lo, hi = max(0, i - SMOOTH), min(len(alts), i + SMOOTH + 1)
smooth_alts.append(sum(alts[lo:hi]) / (hi - lo))
grad_threshold = gradient_pct / 100.0
MIN_HILL_M = 200.0
in_hill = False
hill_start_idx = 0
segments = []
for i in range(1, len(dists)):
d_dist = dists[i] - dists[i - 1]
if d_dist <= 0:
continue
grad = (smooth_alts[i] - smooth_alts[i - 1]) / d_dist
if grad >= grad_threshold and not in_hill:
in_hill = True
hill_start_idx = i - 1
elif grad < grad_threshold and in_hill:
length = dists[i - 1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[i - 1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[i - 1],
))
in_hill = False
if in_hill:
length = dists[-1] - dists[hill_start_idx]
if length >= MIN_HILL_M:
gain = round(smooth_alts[-1] - smooth_alts[hill_start_idx])
start_km = dists[hill_start_idx] / 1000
segments.append((
f"Hill at {start_km:.1f} km (+{gain} m)",
dists[hill_start_idx],
dists[-1],
))
return segments
STANDARD_DISTANCES = [
(400, "400m"),
(800, "800m"),
@@ -1,56 +1,61 @@
"""
Garmin wellness FIT file parser using the official Garmin FIT Python SDK.
The SDK with convert_types_to_strings=True returns snake_case field names.
The official SDK (garmin-fit-sdk) correctly handles:
- Standard FIT messages (monitoring, hrv_status_summary, sleep_level etc.)
- Garmin proprietary messages stored by numeric mesg_num
- Unknown fields stored by field definition number
- Scale/offset application, component expansion, HR merging
Fenix 6X proprietary message numbers identified by binary analysis:
55 - activity accumulation snapshots (cumulative steps, HR per interval)
103 - daily totals summary (total steps, floors, calories)
211 - resting HR + HRV summary
227 - per-minute stress level + heart rate (most valuable for health dashboard)
Sleep stages: message 275 (modern) or 269 (older) each carry a start timestamp
and a stage name. Duration of each stage = gap to the next stage's timestamp.
The sleep session stop time (from event message 21, event_type='stop') closes
the last stage.
"""
from datetime import datetime, timezone, timedelta, date
from datetime import datetime, timezone, date
from typing import Optional
from garmin_fit_sdk import Decoder, Stream
FIT_EPOCH_S = 631065600 # seconds between Unix epoch and FIT epoch (Dec 31 1989)
FIT_EPOCH_S = 631065600
SLEEP_LEVEL_MAP = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
def fit_ts(seconds) -> Optional[datetime]:
"""Convert FIT timestamp to UTC datetime."""
if seconds is None:
def _fit_ts(raw) -> Optional[datetime]:
if raw is None:
return None
try:
s = int(seconds)
if s == 0 or s == 0xFFFFFFFF:
s = int(raw)
if s <= 0 or s == 0xFFFFFFFF:
return None
return datetime.fromtimestamp(s + FIT_EPOCH_S, tz=timezone.utc)
except (TypeError, ValueError, OverflowError, OSError):
return None
def _is_datetime(v) -> bool:
return isinstance(v, datetime)
def _to_date(val) -> Optional[date]:
if val is None:
return None
if isinstance(val, datetime):
if val.tzinfo is None:
val = val.replace(tzinfo=timezone.utc)
return val.date()
if isinstance(val, (int, float)):
dt = _fit_ts(val)
return dt.date() if dt else None
return None
def _to_dt(val) -> Optional[datetime]:
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
if isinstance(val, (int, float)):
return _fit_ts(val)
return None
def parse_wellness_fit(file_path: str) -> dict:
"""
Parse a Garmin wellness/monitoring FIT file using the official Garmin SDK.
Parse a Garmin wellness/monitoring FIT file.
Returns {"days": {date: metrics_dict}, "error": str|None}
"""
try:
from garmin_fit_sdk import Decoder, Stream
except ImportError:
# Fall back to fitparse-based parser if SDK not installed yet
from app.services.wellness_parser_fallback import parse_wellness_fit as _fb
return _fb(file_path)
daily = {} # date -> aggregation dict
daily = {}
last_date_seen = [None]
def ensure_day(d: date) -> dict:
if d not in daily:
@@ -58,195 +63,213 @@ def parse_wellness_fit(file_path: str) -> dict:
"heart_rates": [],
"stress_values": [],
"spo2_readings": [],
"sleep_levels": [],
# Each entry: (datetime, level_int) — duration computed from gaps
"sleep_epochs": [],
"sleep_start": None,
"sleep_end": None,
"steps": None,
"floors_climbed": None,
"active_calories": None,
"total_calories": None,
"bmr": None,
"resting_hr": None,
"hrv_nightly_avg": None,
"hrv_5min_high": None,
"hrv_status": None,
"sleep_score": None,
}
return daily[d]
def get_date(msg: dict, *keys) -> Optional[date]:
"""Extract a date from a message, trying multiple field names."""
for key in keys:
v = msg.get(key)
if v is None:
continue
if _is_datetime(v):
return v.date()
if isinstance(v, (int, float)):
dt = fit_ts(v)
if dt:
return dt.date()
return None
def _add_sleep_epoch(ts: datetime, level_raw):
d = _to_date(ts)
if not d:
return
last_date_seen[0] = d
if isinstance(level_raw, str):
level = SLEEP_LEVEL_MAP.get(level_raw.lower())
else:
level = level_raw
if level is not None:
ensure_day(d)["sleep_epochs"].append((ts, int(level)))
def listener(mesg_num: int, msg: dict):
"""Called for every message after full decoding."""
# ── Standard: monitoring (148) ────────────────────────────────────
if mesg_num == 148:
d = get_date(msg, "timestamp", "local_timestamp")
# ── monitoring_info (147) - older firmware ─────────────────────────
if mesg_num == 147:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
rhr = msg.get("resting_heart_rate")
if d and rhr and 20 < rhr < 120:
last_date_seen[0] = d
ensure_day(d)["resting_hr"] = int(rhr)
# ── monitoring (148) - older firmware ──────────────────────────────
elif mesg_num == 148:
d = _to_date(msg.get("timestamp") or msg.get("local_timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps") or msg.get("cycles")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
entry["stress_values"].append(int(stress))
# ── Standard: monitoring_info (147) ───────────────────────────────
elif mesg_num == 147:
d = get_date(msg, "timestamp", "local_timestamp")
if not d:
return
rhr = msg.get("resting_heart_rate")
if rhr and 20 < rhr < 120:
ensure_day(d)["resting_hr"] = int(rhr)
# ── Standard: hrv_status_summary (275) ────────────────────────────
elif mesg_num == 275:
d = get_date(msg, "timestamp")
# ── monitoring (55) - modern, per-interval running totals ──────────
elif mesg_num == 55:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status")
hr = msg.get("heart_rate")
if hr and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
steps = msg.get("steps")
if steps and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
active_cal = msg.get("active_calories")
if active_cal and active_cal > 0:
entry["active_calories"] = max(entry["active_calories"] or 0, float(active_cal))
ascent = msg.get("ascent")
if ascent and ascent > 0:
# Garmin counts 1 floor ≈ 3 m of ascent
floors = max(1, round(float(ascent) / 3))
entry["floors_climbed"] = max(entry["floors_climbed"] or 0, floors)
# ── monitoring_info (103) - calibration; carries BMR ───────────────
elif mesg_num == 103:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
bmr = msg.get("resting_metabolic_rate")
if bmr and bmr > 0:
ensure_day(d)["bmr"] = int(bmr)
# ── hrv_status_summary (370) - modern HRV ─────────────────────────
elif mesg_num == 370:
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
entry = ensure_day(d)
hrv_avg = msg.get("last_night_average")
if hrv_avg and hrv_avg > 0:
entry["hrv_nightly_avg"] = float(hrv_avg)
hrv_high = msg.get("last_night_5_min_high")
if hrv_high and hrv_high > 0:
entry["hrv_5min_high"] = float(hrv_high)
status = msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── Standard: stress_level (132) ──────────────────────────────────
elif mesg_num == 132:
d = get_date(msg, "stress_level_time", "timestamp")
# ── message 275 - sleep epochs (modern) or HRV (older firmware) ───
elif mesg_num == 275:
sleep_level = msg.get("sleep_level")
ts = _to_dt(msg.get("timestamp"))
if sleep_level is not None and ts:
_add_sleep_epoch(ts, sleep_level)
elif ts:
# Older firmware: HRV summary in message 275
d = _to_date(ts)
if d:
last_date_seen[0] = d
entry = ensure_day(d)
for key in ("weekly_average", "last_night_avg", "hrv_nightly_avg"):
v = msg.get(key)
if v and v > 0:
entry["hrv_nightly_avg"] = float(v)
break
high = msg.get("last_night_5_min_high")
if high:
entry["hrv_5min_high"] = float(high)
status = msg.get("hrv_status") or msg.get("status")
if status:
entry["hrv_status"] = str(status)
# ── sleep_level (269) - older firmware sleep epochs ────────────────
elif mesg_num == 269:
ts = _to_dt(msg.get("timestamp"))
level = msg.get("sleep_level")
if ts and level is not None:
_add_sleep_epoch(ts, level)
# ── event (21) - sleep session start / stop ────────────────────────
elif mesg_num == 21:
ts = _to_dt(msg.get("timestamp"))
if not ts:
return
d = _to_date(ts)
if not d:
return
event_type = msg.get("event_type")
if event_type == "start":
last_date_seen[0] = d
ensure_day(d)["sleep_start"] = ts
elif event_type == "stop":
last_date_seen[0] = d
ensure_day(d)["sleep_end"] = ts
# ── sleep_assessment (346) - overall sleep score, no timestamp ────
elif mesg_num == 346:
d = last_date_seen[0]
if not d:
return
score = msg.get("overall_sleep_score")
if score and score > 0:
ensure_day(d)["sleep_score"] = int(score)
# ── stress_level (132) ─────────────────────────────────────────────
elif mesg_num == 132:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
stress = msg.get("stress_level_value")
if stress is not None and stress >= 0:
ensure_day(d)["stress_values"].append(int(stress))
# ── Standard: spo2_data (258) ─────────────────────────────────────
# ── spo2_data (258) ────────────────────────────────────────────────
elif mesg_num == 258:
d = get_date(msg, "timestamp")
d = _to_date(msg.get("timestamp"))
if not d:
return
last_date_seen[0] = d
spo2 = msg.get("spo2_percent") or msg.get("reading_spo2")
if spo2 and 50 < spo2 <= 100:
ensure_day(d)["spo2_readings"].append(float(spo2))
# ── Standard: sleep_level (269) ───────────────────────────────────
elif mesg_num == 269:
d = get_date(msg, "timestamp")
# ── per-minute stress + HR (227) proprietary ───────────────────────
elif mesg_num == 227:
d = _to_date(msg.get("stress_level_time") or msg.get("timestamp"))
if not d:
return
level = msg.get("sleep_level")
if level is not None:
# Convert string level names to numeric codes if SDK decoded them
if isinstance(level, str):
level_map = {"unmeasurable": 0, "awake": 1, "light": 2, "deep": 3, "rem": 4}
level = level_map.get(level.lower())
if level is not None:
ensure_day(d)["sleep_levels"].append(int(level))
# ── Proprietary 227: per-minute stress + HR ───────────────────────
# field_1 = FIT timestamp, field_2 = heart rate bpm, field_0 = stress
elif mesg_num == 227:
# SDK stores unknown fields as "unknown_N" or by def_num
ts_raw = msg.get(1) or msg.get("unknown_1") or msg.get("field_1")
hr_raw = msg.get(2) or msg.get("unknown_2") or msg.get("field_2")
stress_raw = msg.get(0) or msg.get("unknown_0") or msg.get("field_0")
ts = fit_ts(ts_raw) if isinstance(ts_raw, (int, float)) else (
ts_raw if _is_datetime(ts_raw) else None
)
if not ts:
return
entry = ensure_day(ts.date())
last_date_seen[0] = d
entry = ensure_day(d)
hr_raw = msg.get(2)
if hr_raw and isinstance(hr_raw, (int, float)) and 20 < hr_raw < 250:
entry["heart_rates"].append(int(hr_raw))
stress = msg.get("stress_level_value")
if stress is None:
stress = msg.get(0)
if stress is not None and isinstance(stress, (int, float)) and stress >= 0:
entry["stress_values"].append(int(stress))
if stress_raw is not None and isinstance(stress_raw, (int, float)) and stress_raw >= 0:
entry["stress_values"].append(int(stress_raw))
# ── Proprietary 103: daily totals summary ─────────────────────────
# field_253 = timestamp, field_3 = steps, field_4 = floors, field_5/7 = cal
elif mesg_num == 103:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(3)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = int(steps)
floors = msg.get(4)
if floors and isinstance(floors, (int, float)) and floors > 0:
f = float(floors)
if f > 1000:
f = f / 100
entry["floors_climbed"] = round(f, 1)
active_cal = msg.get(5)
if active_cal and isinstance(active_cal, (int, float)) and active_cal > 0:
entry["active_calories"] = float(active_cal)
total_cal = msg.get(7)
if total_cal and isinstance(total_cal, (int, float)) and total_cal > 0:
entry["total_calories"] = float(total_cal)
# ── Proprietary 211: resting HR + HRV summary ─────────────────────
# ── daily resting HR (211) proprietary ─────────────────────────────
elif mesg_num == 211:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
d = _to_date(msg.get("timestamp"))
if not d:
return
entry = ensure_day(ts.date())
rhr = msg.get(0)
last_date_seen[0] = d
entry = ensure_day(d)
rhr = msg.get("resting_heart_rate") or msg.get("current_day_resting_heart_rate")
if rhr and isinstance(rhr, (int, float)) and 20 < rhr < 120:
entry["resting_hr"] = int(rhr)
hrv = msg.get(1)
if hrv and isinstance(hrv, (int, float)) and 5 < hrv < 300:
entry["hrv_nightly_avg"] = float(hrv)
# ── Proprietary 55: activity accumulation snapshots ───────────────
elif mesg_num == 55:
ts_v = msg.get(253) or msg.get("timestamp")
ts = ts_v if _is_datetime(ts_v) else fit_ts(ts_v)
if not ts:
return
entry = ensure_day(ts.date())
steps = msg.get(2)
if steps and isinstance(steps, (int, float)) and steps > 0:
entry["steps"] = max(entry["steps"] or 0, int(steps))
hr = msg.get(19)
if hr and isinstance(hr, (int, float)) and 20 < hr < 250:
entry["heart_rates"].append(int(hr))
# Decode the file
try:
stream = Stream.from_file(file_path)
decoder = Decoder(stream)
@@ -254,37 +277,57 @@ def parse_wellness_fit(file_path: str) -> dict:
apply_scale_and_offset=True,
convert_datetimes_to_dates=True,
convert_types_to_strings=True,
enable_crc_check=False, # wellness files sometimes have bad CRCs
enable_crc_check=False,
expand_sub_fields=True,
expand_components=True,
merge_heart_rates=True,
merge_heart_rates=False,
mesg_listener=listener,
)
except Exception as e:
return {"error": str(e), "days": {}}
# Aggregate per-day
result = {}
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_levels = data.pop("sleep_levels", [])
sleep_epochs = data.pop("sleep_epochs", [])
sleep_end_ts = data.pop("sleep_end", None)
sleep_start_ts = data.pop("sleep_start", None)
avg_hr = round(sum(hrs) / len(hrs), 1) if hrs else None
max_hr = max(hrs) if hrs else None
avg_stress = round(sum(s for s in stresses if s >= 0) / len(stresses), 1) if stresses else None
spo2_avg = round(sum(spo2s) / len(spo2s), 1) if spo2s else None
# Sleep stage seconds (each level record = 30s epoch)
if sleep_levels:
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) or None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) or None
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) or None
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) or None
sleep_duration_s = (sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0) or None
# Compute sleep stage durations from epoch timestamps
if sleep_epochs:
epochs_sorted = sorted(sleep_epochs, key=lambda x: x[0])
level_secs = {1: 0, 2: 0, 3: 0, 4: 0} # awake, light, deep, rem
for i, (ts, level) in enumerate(epochs_sorted):
if i + 1 < len(epochs_sorted):
next_ts = epochs_sorted[i + 1][0]
elif sleep_end_ts:
next_ts = sleep_end_ts
else:
continue
dur = (next_ts - ts).total_seconds()
if level in level_secs and dur > 0:
level_secs[level] += dur
sleep_deep_s = level_secs[3] or None
sleep_light_s = level_secs[2] or None
sleep_rem_s = level_secs[4] or None
sleep_awake_s = level_secs[1] or None
sleep_duration_s = (level_secs[2] + level_secs[3] + level_secs[4]) or None
sleep_stages = [[int(ts.timestamp() * 1000), level] for ts, level in epochs_sorted]
else:
sleep_deep_s = sleep_light_s = sleep_rem_s = sleep_awake_s = sleep_duration_s = None
sleep_stages = None
active_cal = data.get("active_calories")
bmr = data.get("bmr")
# Require active_cal so we don't store BMR-only as "total" calories
total_cal = float(bmr + active_cal) if (bmr and active_cal) else None
result[day_date] = {
"resting_hr": data.get("resting_hr"),
@@ -297,13 +340,17 @@ def parse_wellness_fit(file_path: str) -> dict:
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"floors_climbed": data.get("floors_climbed"),
"active_calories": data.get("active_calories"),
"total_calories": data.get("total_calories"),
"active_calories": active_cal,
"total_calories": total_cal,
"sleep_duration_s": sleep_duration_s,
"sleep_deep_s": sleep_deep_s,
"sleep_light_s": sleep_light_s,
"sleep_rem_s": sleep_rem_s,
"sleep_awake_s": sleep_awake_s,
"sleep_score": data.get("sleep_score"),
"sleep_start": sleep_start_ts,
"sleep_end": sleep_end_ts,
"sleep_stages": sleep_stages,
}
return {"days": result, "error": None}