Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ An [OpenClaw](https://openclaw.ai) skill that syncs your daily health data from

## What it syncs

- **Sleep** — duration, stages (deep/light/REM/awake), sleep score
- **Sleep** — duration, stages (deep/light/REM/awake), sleep score, sleep need, sleep factors (stress, recovery, etc.)
- **Lifestyle** — alcohol, caffeine, late meal, illness (if logged)
- **Body** — steps, calories, distance, floors
- **Heart** — resting HR, max HR, HRV
- **Body Battery & SpO2**
Expand All @@ -24,6 +25,13 @@ An [OpenClaw](https://openclaw.ai) skill that syncs your daily health data from
## Sleep: 8h 39m (Good)
Deep: 1h 50m | Light: 4h 30m | REM: 2h 19m | Awake: 0h 54m
Sleep Score: 85
Sleep Need: 8h 00m
Sleep Factors: Sleep Duration: Good | Stress: Fair | Recovery: Good

## Lifestyle
- Alcohol: 1 drink
- Caffeine: 2 cups
- Late Meal: Yes

## Body: 9,720 steps | 2,317 cal
Distance: 8.0 km | Floors: 42
Expand Down
91 changes: 91 additions & 0 deletions scripts/sync_garmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""Sync daily health data from Garmin Connect into markdown files."""

import argparse
import re
import sys
import time
from datetime import date, timedelta
Expand Down Expand Up @@ -175,6 +176,92 @@ def fetch_sleep(client: Garmin, day: str) -> str | None:
if score is not None:
lines.append(f"Sleep Score: {score}")

sleep_need = daily.get("sleepNeed")
if sleep_need:
# sleepNeed can be a dict (e.g. {'actual': 480, ...}) or just a number
# Note: 'actual' seems to be in minutes, fmt_duration expects seconds
if isinstance(sleep_need, dict):
# Try common keys
val = sleep_need.get("actual") or sleep_need.get("value") or sleep_need.get("duration")
if val is not None:
# If value is small (e.g. 480), assume minutes -> convert to seconds
if val < 1440: # less than 24 hours in minutes
val *= 60
lines.append(f"Sleep Need: {fmt_duration(val)}")
else:
# If it's just a number, assume seconds if large, minutes if small?
# For now, let's assume seconds if it matches existing behavior or fix if needed
lines.append(f"Sleep Need: {fmt_duration(sleep_need)}")

# Extract all sleep factors (Alcohol, Caffeine, Late Meal, Stress, Recovery, etc.)
factors = daily.get("sleepScores", {}).get("overall", {}).get("factors", [])
factor_parts = []
for f in factors:
key = f.get("factorKey")
if not key:
continue

status = f.get("status", "").replace("_", " ").title()
# Format key: 'sleepDuration' -> 'Sleep Duration' or 'ALCOHOL' -> 'Alcohol'
display_key = re.sub(r'([a-z])([A-Z])', r'\1 \2', key).replace('_', ' ').title()
factor_parts.append(f"{display_key}: {status}")

if factor_parts:
lines.append("Sleep Factors: " + " | ".join(factor_parts))

return "\n".join(lines)


def fetch_lifestyle(client: Garmin, day: str) -> str | None:
"""Fetch and format lifestyle logging data (Alcohol, Caffeine, Late Meal, Illness, etc.)."""
try:
# get_lifestyle_logging_data was added in garminconnect 0.2.35
data = client.get_lifestyle_logging_data(day)
except Exception as e:
if VERBOSE:
print(f" [verbose] Lifestyle logging fetch failed: {e}", file=sys.stderr)
return None

if VERBOSE:
import json
print(f" [verbose] Raw Lifestyle Data for {day}: {json.dumps(data, indent=2)}", file=sys.stderr)

if not data:
return None

# Lifestyle data is in dailyLogsReport
logs = data.get("dailyLogsReport", [])
if not logs:
return None

lines = ["## Lifestyle"]
for log in logs:
# Only include things that were logged as "YES"
if log.get("logStatus") != "YES":
continue

name = log.get("name") or str(log.get("behaviourId", ""))

# Check for amounts in details
details = log.get("details", [])
detail_strs = []
for d in details:
amount = d.get("amount")
sub_type = d.get("subTypeName")
if amount is not None:
if sub_type:
detail_strs.append(f"{amount} {sub_type.lower()}")
else:
detail_strs.append(f"{amount}")

line = f"- {name}"
if detail_strs:
line += f": {', '.join(detail_strs)}"
lines.append(line)

if len(lines) == 1:
return None

return "\n".join(lines)


Expand Down Expand Up @@ -550,6 +637,10 @@ def sync_day(client: Garmin, day: date, output_dir: Path) -> None:
if sleep:
sections.append(sleep)

lifestyle = fetch_lifestyle(client, day_str)
if lifestyle:
sections.append(lifestyle)

body = fetch_body(client, day_str)
if body:
sections.append(body)
Expand Down