Populate sleep_stages hypnogram timeline from Garmin Connect sync
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 7s
Build and push images / build-worker (push) Successful in 6s
Build and push images / build-frontend (push) Successful in 6s

Parse Garmin's sleepLevels array in _parse_day into the existing
[[ts_ms, level], ...] format (matching the FIT parser's encoding) so the
SleepHypnogram lights up for Garmin-Connect-synced days, not just _SLEEP.fit
uploads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:21:21 +01:00
parent 319b04e413
commit e3964bbcdc
@@ -310,6 +310,8 @@ def sync_wellness(garmin, user_id: int, since: Optional[datetime], db,
row["intraday_hr"] = _json.dumps(row["intraday_hr"]) row["intraday_hr"] = _json.dumps(row["intraday_hr"])
if "body_battery" in row and not isinstance(row["body_battery"], str): if "body_battery" in row and not isinstance(row["body_battery"], str):
row["body_battery"] = _json.dumps(row["body_battery"]) 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()) cols = list(row.keys())
col_sql = ", ".join(cols) col_sql = ", ".join(cols)
@@ -585,6 +587,14 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
elif isinstance(scores, (int, float)): elif isinstance(scores, (int, float)):
row["sleep_score"] = scores 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: if hrv_data:
summary = hrv_data.get("hrvSummary") or hrv_data summary = hrv_data.get("hrvSummary") or hrv_data
_set(row, "hrv_nightly_avg", summary.get("lastNight") or summary.get("lastNightAvg")) _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): def _set(d: dict, key: str, val):
if val is not None: if val is not None:
d[key] = val 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