diff --git a/backend/app/services/garmin_connect_sync.py b/backend/app/services/garmin_connect_sync.py index c2244f8..6e4d537 100644 --- a/backend/app/services/garmin_connect_sync.py +++ b/backend/app/services/garmin_connect_sync.py @@ -310,6 +310,8 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db, row["intraday_hr"] = _json.dumps(row["intraday_hr"]) if "body_battery" in row and not isinstance(row["body_battery"], str): row["body_battery"] = _json.dumps(row["body_battery"]) + if "sleep_stages" in row and not isinstance(row["sleep_stages"], str): + row["sleep_stages"] = _json.dumps(row["sleep_stages"]) cols = list(row.keys()) col_sql = ", ".join(cols) @@ -585,6 +587,14 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict: elif isinstance(scores, (int, float)): row["sleep_score"] = scores + # Per-stage timeline (hypnogram). Garmin's sleepLevels array gives one + # entry per contiguous stage with startGMT/endGMT and an activityLevel + # code. Map it to the internal [[ts_ms, level], ...] format shared with + # the FIT parser (0=unmeasurable,1=awake,2=light,3=deep,4=rem). + stages = _gc_sleep_stages(sleep_data) + if stages: + row["sleep_stages"] = stages + if hrv_data: summary = hrv_data.get("hrvSummary") or hrv_data _set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg")) @@ -599,3 +609,48 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict: def _set(d: dict, key: str, val): if val is not None: d[key] = val + + +# Garmin Connect sleepLevels.activityLevel → internal stage code +# (internal: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem — matches the FIT +# parser's SLEEP_LEVEL_MAP so both sources produce identical sleep_stages). +_GC_STAGE_MAP = {0: 3, 1: 2, 2: 4, 3: 1} # deep, light, rem, awake + + +def _gc_ms(val) -> Optional[int]: + """Garmin timestamps: epoch-ms ints, or GMT ISO strings (naive ⇒ UTC).""" + if val is None: + return None + if isinstance(val, (int, float)): + return int(val) + try: + s = str(val).replace("Z", "") + dt = datetime.fromisoformat(s) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp() * 1000) + except (ValueError, TypeError): + return None + + +def _gc_sleep_stages(sleep_data: dict) -> Optional[list]: + """Convert Garmin Connect `sleepLevels` into [[ts_ms, level], ...] (UTC, sorted).""" + levels = sleep_data.get("sleepLevels") + if not isinstance(levels, list): + return None + out = [] + for lv in levels: + if not isinstance(lv, dict): + continue + ts = _gc_ms(lv.get("startGMT")) + al = lv.get("activityLevel") + if ts is None or al is None: + continue + try: + mapped = _GC_STAGE_MAP.get(int(float(al))) + except (ValueError, TypeError): + mapped = None + if mapped is not None: + out.append([ts, mapped]) + out.sort(key=lambda p: p[0]) + return out or None