Populate sleep_stages hypnogram timeline from Garmin Connect sync
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user