#!/usr/bin/env python3 # /// script # dependencies = [ # "garminconnect>=0.2.14", # "pytz>=2024.1", # ] # /// """ Garmin Sync: Deterministic mirror of Garmin Connect data into git-backed JSONL. Mirrors daily summaries, sleep data, and activities into: - data/garmin/daily/.jsonl - data/garmin/sleep/.jsonl - data/garmin/activities/.jsonl Adheres to ADR 0003 (Git as single source of truth) and ADR 0004 (Structured Datastore). Zero LLM math: records are stored as raw JSONL for deterministic DuckDB ingestion. """ from __future__ import annotations import argparse import datetime import json import logging import os import sys import urllib.error import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional try: import pytz except ImportError: pytz = None logger = logging.getLogger("garmin_sync") def get_berlin_yesterday() -> str: """Returns yesterday's date in Europe/Berlin wall-clock time.""" if pytz: tz = pytz.timezone("Europe/Berlin") now = datetime.datetime.now(tz) else: # Fallback +2h offset for Berlin CEST tz_offset = datetime.timezone(datetime.timedelta(hours=2)) now = datetime.datetime.now(tz_offset) yesterday = now - datetime.timedelta(days=1) return yesterday.strftime("%Y-%m-%d") def find_default_data_dir() -> Path: """Finds the knowledge-wiki/data directory.""" env_dir = os.environ.get("DATASTORE_DATA_DIR") if env_dir: p = Path(env_dir) if p.is_dir(): return p candidates = [ Path("/home/node/.openclaw/wiki/main/data"), Path(__file__).resolve().parents[4] / "knowledge-wiki" / "data", Path.cwd() / "knowledge-wiki" / "data", Path.cwd() / "data", ] for c in candidates: if c.is_dir(): return c # Fallback to local data dir inside parent return Path.cwd() / "data" def get_token_dir(custom_path: Optional[str] = None) -> Path: """Discovers token directory according to garmin-auth conventions.""" if custom_path: return Path(custom_path) if os.environ.get("GARMINTOKENS"): return Path(os.path.expanduser(os.environ["GARMINTOKENS"])) if Path("/home/node/.openclaw/garmin_tokens").is_dir(): return Path("/home/node/.openclaw/garmin_tokens") return Path(os.path.expanduser("~/.garminconnect")) class GarminSyncClient: """Garmin API client with token re-use and optional mock mode.""" def __init__(self, token_dir: Path, mock: bool = False): self.token_dir = token_dir self.mock = mock self.client: Any = None if not self.mock: self._init_live_client() def _init_live_client(self): token_file = self.token_dir / "garmin_tokens.json" if not token_file.is_file(): logger.error(f"No Garmin tokens found at {token_file}") raise FileNotFoundError(f"Missing garmin_tokens.json in {self.token_dir}") try: from garminconnect import Garmin self.client = Garmin() self.client.login(str(self.token_dir)) name = self.client.get_full_name() logger.info(f"Connected to Garmin as {name}") except Exception as e: logger.error(f"Failed to authenticate with Garmin Connect: {e}") raise PermissionError(f"Garmin auth failed: {e}") from e def get_daily_summary(self, date_str: str) -> Dict[str, Any]: if self.mock: return { "calendarDate": date_str, "totalKilocalories": 2850, "activeKilocalories": 610, "bmrKilocalories": 2240, "totalSteps": 9840, "totalDistanceMeters": 7450.0, "restingHeartRate": 52, "minHeartRate": 48, "maxHeartRate": 164, "floorsAscended": 12, "source": "garmin_sync_mock", } return self.client.get_user_summary(date_str) def get_sleep_data(self, date_str: str) -> Dict[str, Any]: if self.mock: return { "dailySleepDTO": { "id": 12345678, "calendarDate": date_str, "sleepTimeSeconds": 28800, "napTimeSeconds": 0, "deepSleepSeconds": 7200, "lightSleepSeconds": 14400, "remSleepSeconds": 5400, "awakeSleepSeconds": 1800, "sleepScores": {"overall": {"value": 85, "qualifierKey": "EXCELLENT"}}, }, "source": "garmin_sync_mock", } return self.client.get_sleep_data(date_str) def get_activities(self, date_str: str) -> List[Dict[str, Any]]: if self.mock: return [ { "activityId": 987654321, "activityName": "Indoor Cycling (Tacx Flux 2)", "startTimeLocal": f"{date_str} 18:30:00", "startTimeGMT": f"{date_str} 16:30:00", "activityType": {"typeId": 2, "typeKey": "cycling"}, "distance": 25400.0, "duration": 3600.0, "elapsedDuration": 3600.0, "calories": 580.0, "averageSpeed": 7.05, "maxSpeed": 11.2, "averageHR": 142, "maxHR": 168, "averagePower": 210, "maxPower": 285, "source": "garmin_sync_mock", } ] return self.client.get_activities_by_date(date_str, date_str) class GarminMirrorWriter: """Manages month-partitioned JSONL files with idempotent upserts.""" def __init__(self, data_dir: Path): self.data_dir = data_dir self.garmin_dir = data_dir / "garmin" self.daily_dir = self.garmin_dir / "daily" self.sleep_dir = self.garmin_dir / "sleep" self.unified_sleep_dir = data_dir / "sleep" self.activities_dir = self.garmin_dir / "activities" for d in [self.daily_dir, self.sleep_dir, self.unified_sleep_dir, self.activities_dir]: d.mkdir(parents=True, exist_ok=True) def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]: if not file_path.is_file(): return [] items = [] with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: try: items.append(json.loads(line)) except json.JSONDecodeError: continue return items def _write_atomic(self, file_path: Path, items: List[Dict[str, Any]]): tmp_path = file_path.with_suffix(".tmp") with open(tmp_path, "w", encoding="utf-8") as f: for item in items: f.write(json.dumps(item, ensure_ascii=False) + "\n") tmp_path.replace(file_path) def upsert_daily_summary(self, summary: Dict[str, Any]): cal_date = summary.get("calendarDate") if not cal_date: logger.warning("Daily summary missing calendarDate, skipping.") return month = cal_date[:7] # YYYY-MM target_file = self.daily_dir / f"{month}.jsonl" existing = self._read_existing(target_file) updated = False new_items = [] for item in existing: if item.get("calendarDate") == cal_date: new_items.append(summary) updated = True else: new_items.append(item) if not updated: new_items.append(summary) new_items.sort(key=lambda x: str(x.get("calendarDate", ""))) self._write_atomic(target_file, new_items) logger.info(f"Upserted daily summary for {cal_date} into {target_file.name}") def upsert_sleep_data(self, sleep_data: Dict[str, Any]): cal_date = None dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {} if dto: cal_date = dto.get("calendarDate") if not cal_date: cal_date = sleep_data.get("calendarDate") if not cal_date: logger.warning("Sleep data missing calendarDate, skipping.") return month = cal_date[:7] target_file = self.sleep_dir / f"{month}.jsonl" existing = self._read_existing(target_file) updated = False new_items = [] for item in existing: item_date = None if "dailySleepDTO" in item and isinstance(item["dailySleepDTO"], dict): item_date = item["dailySleepDTO"].get("calendarDate") if not item_date: item_date = item.get("calendarDate") if item_date == cal_date: new_items.append(sleep_data) updated = True else: new_items.append(item) if not updated: new_items.append(sleep_data) def _sort_key(x): d = (x.get("dailySleepDTO") or {}).get("calendarDate") if isinstance(x.get("dailySleepDTO"), dict) else None return str(d or x.get("calendarDate") or "") new_items.sort(key=_sort_key) self._write_atomic(target_file, new_items) # Also upsert into unified data/sleep/.jsonl start_gmt = dto.get("sleepStartTimestampGMT") end_gmt = dto.get("sleepEndTimestampGMT") if start_gmt: start_dt = datetime.datetime.fromtimestamp(start_gmt / 1000, datetime.timezone.utc) start_iso = start_dt.isoformat() else: start_iso = f"{cal_date}T22:30:00+02:00" if end_gmt: end_dt = datetime.datetime.fromtimestamp(end_gmt / 1000, datetime.timezone.utc) end_iso = end_dt.isoformat() else: end_iso = f"{cal_date}T06:30:00+02:00" nap_sec = dto.get("napTimeSeconds") or 0 sleep_sec = dto.get("sleepTimeSeconds") or 0 sleep_type = "nap" if (nap_sec > 0 and sleep_sec == 0) else "night" unified_rec = { "id": str(dto.get("id") or (int(start_gmt) if start_gmt else cal_date)), "subject": "moritz", "calendarDate": cal_date, "sleepType": sleep_type, "location": "bett", "isOpen": False, "startIso": start_iso, "endIso": end_iso, "sleepStartTimestampGMT": dto.get("sleepStartTimestampGMT"), "sleepEndTimestampGMT": dto.get("sleepEndTimestampGMT"), "sleepStartTimestampLocal": dto.get("sleepStartTimestampLocal"), "sleepEndTimestampLocal": dto.get("sleepEndTimestampLocal"), "sleepTimeSeconds": dto.get("sleepTimeSeconds"), "napTimeSeconds": nap_sec, "deepSleepSeconds": dto.get("deepSleepSeconds"), "lightSleepSeconds": dto.get("lightSleepSeconds"), "remSleepSeconds": dto.get("remSleepSeconds"), "awakeSleepSeconds": dto.get("awakeSleepSeconds"), "awakeCount": dto.get("awakeCount"), "avgSleepStress": dto.get("avgSleepStress"), "avgHeartRate": dto.get("avgHeartRate"), "averageSpO2Value": dto.get("averageSpO2Value"), "lowestSpO2Value": dto.get("lowestSpO2Value"), "highestSpO2Value": dto.get("highestSpO2Value"), "averageRespirationValue": dto.get("averageRespirationValue"), "sleepScores": dto.get("sleepScores"), "avgOvernightHrv": sleep_data.get("avgOvernightHrv"), "hrvStatus": sleep_data.get("hrvStatus"), "bodyBatteryChange": sleep_data.get("bodyBatteryChange"), "restingHeartRate": sleep_data.get("restingHeartRate"), "restlessMomentsCount": sleep_data.get("restlessMomentsCount"), "note": None, "actor": "garmin_sync", "created_at": start_iso, "updated_at": None, "deleted_at": None, } unified_file = self.unified_sleep_dir / f"{month}.jsonl" existing_unified = self._read_existing(unified_file) updated_u = False new_unified = [] for item in existing_unified: if item.get("subject") == "moritz" and item.get("calendarDate") == cal_date: new_unified.append(unified_rec) updated_u = True else: new_unified.append(item) if not updated_u: new_unified.append(unified_rec) new_unified.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", "")))) self._write_atomic(unified_file, new_unified) logger.info(f"Upserted sleep data for {cal_date} into {target_file.name} and {unified_file.name}") def upsert_activities(self, activities: List[Dict[str, Any]]): if not activities: return # Group activities by month by_month: Dict[str, List[Dict[str, Any]]] = {} for act in activities: start_local = act.get("startTimeLocal", "") if len(start_local) >= 7: month = start_local[:7] else: month = datetime.date.today().strftime("%Y-%m") by_month.setdefault(month, []).append(act) for month, act_list in by_month.items(): target_file = self.activities_dir / f"{month}.jsonl" existing = self._read_existing(target_file) id_map = {item.get("activityId"): item for item in existing if item.get("activityId")} for act in act_list: act_id = act.get("activityId") if act_id: id_map[act_id] = act all_acts = list(id_map.values()) all_acts.sort(key=lambda x: str(x.get("startTimeLocal", ""))) self._write_atomic(target_file, all_acts) logger.info(f"Upserted {len(act_list)} activities for month {month} into {target_file.name}") def sync_date_range( client: GarminSyncClient, writer: Optional[GarminMirrorWriter], dates: List[str], backend_url: Optional[str] = None, ): target_backend = None if backend_url is not None: target_backend = backend_url elif os.environ.get("DUCKDB_FORCE_LOCAL") != "1": env_backend = os.environ.get( "DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1" ).rstrip("/") try: base = env_backend.replace("/duckdb-api/v1", "") test_url = f"{base}/healthz" req = urllib.request.Request(test_url, method="GET") with urllib.request.urlopen(req, timeout=1.0) as r: if r.status == 200: target_backend = env_backend logger.info(f"GarminSync using single-writer backend at {target_backend}") except Exception: target_backend = None for d in dates: logger.info(f"--- Syncing Garmin data for {d} ---") summary = None sleep = None activities = None try: summary = client.get_daily_summary(d) except Exception as e: logger.warning(f"Could not fetch daily summary for {d}: {e}") try: sleep = client.get_sleep_data(d) except Exception as e: logger.warning(f"Could not fetch sleep data for {d}: {e}") try: activities = client.get_activities(d) except Exception as e: logger.warning(f"Could not fetch activities for {d}: {e}") synced_via_backend = False if target_backend: try: sync_payload = { "date": d, "daily": [summary] if summary else [], "sleep": [sleep] if sleep else [], "activities": activities or [], "daily_summary": summary, "sleep_data": sleep, } sync_url = f"{target_backend}/garmin/sync" req = urllib.request.Request( sync_url, data=json.dumps(sync_payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=30) as resp: resp_body = {} try: resp_body = json.loads(resp.read().decode("utf-8")) except Exception: pass expected_records = (1 if summary else 0) + (1 if sleep else 0) + (len(activities) if activities else 0) synced_records = ( resp_body.get("synced_daily", 0) + resp_body.get("synced_sleep", 0) + resp_body.get("synced_activities", 0) ) if resp.status == 200 and (expected_records == 0 or synced_records > 0): logger.info(f"Successfully posted Garmin sync for {d} to backend: {resp_body}") synced_via_backend = True else: logger.warning( f"Backend returned HTTP {resp.status} for {d} but synced {synced_records} records " f"(expected {expected_records}): {resp_body}. Falling back to local writer." ) except Exception as e: logger.warning(f"Failed to sync via backend {target_backend} for {d}: {e}. Falling back to local writer if available.") if not synced_via_backend and writer: if summary: writer.upsert_daily_summary(summary) if sleep: writer.upsert_sleep_data(sleep) if activities: writer.upsert_activities(activities) def generate_date_list(start_str: str, end_str: str) -> List[str]: d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date() d_end = datetime.datetime.strptime(end_str, "%Y-%m-%d").date() if d_start > d_end: d_start, d_end = d_end, d_start result = [] curr = d_start while curr <= d_end: result.append(curr.strftime("%Y-%m-%d")) curr += datetime.timedelta(days=1) return result def main(): parser = argparse.ArgumentParser(description="Deterministic Garmin Connect data mirror into JSONL") parser.add_argument("--date", help="Specific date to sync (YYYY-MM-DD). Defaults to yesterday.") parser.add_argument("--days", type=int, help="Sync last N days up to yesterday.") parser.add_argument("--start", help="Start date (YYYY-MM-DD)") parser.add_argument("--end", help="End date (YYYY-MM-DD)") parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory") parser.add_argument("--token-dir", help="Path to Garmin token directory") parser.add_argument("--mock", action="store_true", help="Run with mock data (for testing/offline)") parser.add_argument("--verbose", action="store_true", help="Enable verbose debug logging") args = parser.parse_args() logging.basicConfig( level=logging.DEBUG if args.verbose else logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) data_dir = Path(args.data_dir) if args.data_dir else find_default_data_dir() token_dir = get_token_dir(args.token_dir) # Determine date list if args.start and args.end: dates = generate_date_list(args.start, args.end) elif args.days: yesterday_str = get_berlin_yesterday() d_yesterday = datetime.datetime.strptime(yesterday_str, "%Y-%m-%d").date() d_start = d_yesterday - datetime.timedelta(days=args.days - 1) dates = generate_date_list(d_start.strftime("%Y-%m-%d"), yesterday_str) elif args.date: dates = [args.date] else: dates = [get_berlin_yesterday()] logger.info(f"Starting Garmin sync for {len(dates)} day(s): {dates[0]} to {dates[-1]}") logger.info(f"Data directory: {data_dir}") try: client = GarminSyncClient(token_dir, mock=args.mock) except PermissionError: logger.error("Authentication error. Please run 'garmin-auth' to refresh credentials.") sys.exit(2) except FileNotFoundError as e: logger.error(str(e)) sys.exit(2) writer = GarminMirrorWriter(data_dir) sync_date_range(client, writer, dates) logger.info("Garmin sync completed successfully.") sys.exit(0) if __name__ == "__main__": main()