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"])
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user