Files
mop-skills/skills/datastore/scripts/garmin_sync.py
T

390 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Garmin Sync: Deterministic mirror of Garmin Connect data into git-backed JSONL.
Mirrors daily summaries, sleep data, and activities into:
- data/garmin/daily/<YYYY-MM>.jsonl
- data/garmin/sleep/<YYYY-MM>.jsonl
- data/garmin/activities/<YYYY-MM>.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
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.garmin_dir = data_dir / "garmin"
self.daily_dir = self.garmin_dir / "daily"
self.sleep_dir = self.garmin_dir / "sleep"
self.activities_dir = self.garmin_dir / "activities"
for d in [self.daily_dir, self.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
if "dailySleepDTO" in sleep_data and isinstance(sleep_data["dailySleepDTO"], dict):
cal_date = sleep_data["dailySleepDTO"].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)
logger.info(f"Upserted sleep data for {cal_date} into {target_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: GarminMirrorWriter, dates: List[str]):
for d in dates:
logger.info(f"--- Syncing Garmin data for {d} ---")
try:
summary = client.get_daily_summary(d)
if summary:
writer.upsert_daily_summary(summary)
except Exception as e:
logger.warning(f"Could not fetch daily summary for {d}: {e}")
try:
sleep = client.get_sleep_data(d)
if sleep:
writer.upsert_sleep_data(sleep)
except Exception as e:
logger.warning(f"Could not fetch sleep data for {d}: {e}")
try:
activities = client.get_activities(d)
if activities:
writer.upsert_activities(activities)
except Exception as e:
logger.warning(f"Could not fetch activities for {d}: {e}")
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()