From f41316c8cf746f320068a5953b8d34700d8dbbe3 Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 7 Aug 2025 12:43:38 -0700 Subject: [PATCH] python --- .env.example | 7 ++ Design.md | 85 +++++++++++++++++ Dockerfile | 24 +++++ main.py | 235 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + 5 files changed, 354 insertions(+) create mode 100644 .env.example create mode 100644 Design.md create mode 100644 Dockerfile create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..331f4e1 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Garmin Connect credentials +GARMIN_EMAIL=your_email@example.com +GARMIN_PASSWORD=your_password + +# Optional configuration +DOWNLOAD_DIR=/data +DB_PATH=/app/garmin.db diff --git a/Design.md b/Design.md new file mode 100644 index 0000000..7e13d61 --- /dev/null +++ b/Design.md @@ -0,0 +1,85 @@ +# GarminSync Application Design + +## Basic Info +**App Name:** GarminSync +**What it does:** CLI application that downloads FIT files for every activity in Garmin Connect + +## Core Features +1. List activities (`garminsync list --all`) +2. List activities that have not been downloaded (`garminsync list --missing`) +3. List activities that have been downloaded (`garminsync list --downloaded`) +4. Download missing activities (`garminsync download --missing`) + +## Tech Stack +**Frontend:** Python CLI (argparse) +**Backend:** Python 3.10+ with garminexport==1.2.0 +**Database:** SQLite (garmin.db) +**Hosting:** Docker container +**Key Libraries:** garminexport, python-dotenv, sqlite3 + +## Data Structure +**Main data object:** +``` +Activity: +- activity_id: INTEGER (primary key, from Garmin) +- start_time: TEXT (ISO 8601 format) +- filename: TEXT (unique, e.g., activity_123_20240807.fit) +- downloaded: BOOLEAN (0 = pending, 1 = completed) +``` + +## User Flow +1. User launches container with credentials: `docker run -it --env-file .env garminsync` +2. User is presented with CLI menu of options +3. User selects command (e.g., `garminsync download --missing`) +4. Application executes task with progress indicators +5. Application displays completion status and summary + +## File Structure +``` +GarminSync/ +├── Dockerfile +├── .env.example +├── requirements.txt +└── main.py +``` + +## Technical Implementation Notes +- **Single-file architecture:** All logic in main.py (CLI, DB, Garmin integration) +- **Authentication:** Credentials via GARMIN_EMAIL/GARMIN_PASSWORD env vars (never stored) +- **File naming:** `activity_{id}_{timestamp}.fit` (e.g., activity_123456_20240807.fit) +- **Rate limiting:** 2-second delays between API requests +- **Database:** In-memory during auth testing, persistent garmin.db for production +- **Docker** All docker commands require the use of sudo + +## Development Phases +### Phase 1: Core Infrastructure +- [X] Dockerfile with Python 3.10 base +- [X] Environment variable handling +- [X] garminexport client initialization + +### Phase 2: Activity Listing +- [ ] SQLite schema implementation +- [ ] Activity listing commands +- [ ] Database synchronization + +### Phase 3: Download Pipeline +- [ ] FIT file download implementation +- [ ] Idempotent download logic +- [ ] Database update on success + +### Phase 4: Polish +- [ ] Progress indicators +- [ ] Error handling +- [ ] README documentation + +## Critical Roadblocks +1. **Garmin API changes:** garminexport is abandoned, switch to garmin-connect-export instead +2. **Rate limiting:** Built-in 2-second request delays +3. **Session management:** Automatic cookie handling via garminexport +4. **File conflicts:** Atomic database updates during downloads +5. **Docker permissions:** Volume-mounted /data directory for downloads + +## Current Status +**Working on:** Phase 1 - Core Infrastructure (Docker setup, env vars) +**Next steps:** Implement activity listing with SQLite schema +**Known issues:** Garmin API rate limits (mitigated by 2s delays), session timeout handling diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c25724d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +# Use official Python 3.10 slim image +FROM python:3.10-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy and install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application files +COPY .env.example ./ +COPY main.py ./ + +# Set container permissions +RUN chmod +x main.py + +# Command to run the application +ENTRYPOINT ["python", "main.py"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..6df0386 --- /dev/null +++ b/main.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +import os +import time +import argparse +from dotenv import load_dotenv +import sqlite3 +from datetime import datetime, timedelta +from pathlib import Path +import urllib.request +import json +import logging + +def create_schema(db_path="garmin.db"): + """Create SQLite schema for activity tracking""" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS Activity ( + activity_id INTEGER PRIMARY KEY, + start_time TEXT NOT NULL, + filename TEXT UNIQUE NOT NULL, + downloaded BOOLEAN NOT NULL DEFAULT 0 + ) + ''') + conn.commit() + conn.close() + +def initialize_garmin_client(): + """Initialize authenticated Garmin client with rate limiting""" + load_dotenv() + + email = os.getenv("GARMIN_EMAIL") + password = os.getenv("GARMIN_PASSWORD") + + if not email or not password: + raise ValueError("Missing GARMIN_EMAIL or GARMIN_PASSWORD environment variables") + + import garth + # Add 2-second delay before API calls (rate limit mitigation) + time.sleep(2) + garth.login(email, password) + return garth, email + +def get_garmin_activities(garth_client): + """Fetch activity IDs and start times from Garmin Connect""" + url = "https://connect.garmin.com/activitylist-service/activities/search/activities?start=0&limit=100" + req = urllib.request.Request(url) + req.add_header('authorization', str(garth_client.client.oauth2_token)) + req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2816.0 Safari/537.36') + req.add_header('nk', 'NT') + + try: + response = urllib.request.urlopen(req) + data = json.loads(response.read()) + activities = [] + for activity in data: + activities.append((activity['activityId'], activity['startTimeLocal'])) + return activities + except Exception as e: + print(f"Error fetching activities: {str(e)}") + return [] + +def sync_with_garmin(client, email): + """Sync Garmin activities with local database""" + db_path = "garmin.db" + data_dir = Path("/data") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get activity IDs from Garmin API + activity_ids = get_garmin_activities(client) + + for activity_id, start_time in activity_ids: + timestamp = datetime.fromisoformat(start_time).strftime("%Y%m%d") + filename = f"activity_{activity_id}_{timestamp}.fit" + + # Check if file exists in data directory + file_path = data_dir / filename + downloaded = 1 if file_path.exists() else 0 + + # Insert or update activity record + cursor.execute(''' + INSERT INTO Activity (activity_id, start_time, filename, downloaded) + VALUES (?, ?, ?, ?) + ON CONFLICT(activity_id) DO UPDATE SET + downloaded = ? + ''', (activity_id, start_time, filename, downloaded, downloaded)) + + conn.commit() + conn.close() + +def download_activity(garth_client, activity_id, filename): + """Download a single activity FIT file from Garmin Connect""" + data_dir = Path("/data") + data_dir.mkdir(exist_ok=True) + + url = f"https://connect.garmin.com/modern/proxy/download-service/export/{activity_id}/fit" + req = urllib.request.Request(url) + req.add_header('authorization', str(garth_client.client.oauth2_token)) + req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2816.0 Safari/537.36') + req.add_header('nk', 'NT') + + try: + logging.info(f"Downloading activity {activity_id} to {filename}") + response = urllib.request.urlopen(req) + file_path = data_dir / filename + with open(file_path, 'wb') as f: + f.write(response.read()) + return True + except Exception as e: + logging.error(f"Error downloading activity {activity_id}: {str(e)}") + return False + +def download_missing_activities(garth_client): + """Download all activities that are not yet downloaded""" + db_path = "garmin.db" + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # Get all missing activities + cursor.execute("SELECT activity_id, filename FROM Activity WHERE downloaded = 0") + missing_activities = cursor.fetchall() + + conn.close() + + if not missing_activities: + print("No missing activities to download") + return False + + print(f"Found {len(missing_activities)} missing activities to download:") + for activity_id, filename in missing_activities: + print(f"Downloading {filename}...") + success = download_activity(garth_client, activity_id, filename) + if success: + # Update database + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("UPDATE Activity SET downloaded = 1 WHERE activity_id = ?", (activity_id,)) + conn.commit() + conn.close() + time.sleep(2) # Rate limiting + + return True + +def get_activities(where_clause=None): + """Retrieve activities from database based on filter""" + conn = sqlite3.connect("garmin.db") + cursor = conn.cursor() + + base_query = "SELECT activity_id, start_time, filename, downloaded FROM Activity" + if where_clause: + base_query += f" WHERE {where_clause}" + + cursor.execute(base_query) + results = cursor.fetchall() + conn.close() + return results + +def main(): + create_schema() + parser = argparse.ArgumentParser(description="GarminSync CLI") + subparsers = parser.add_subparsers(dest="command", required=True) + + # Auth test command (Phase 1) + auth_parser = subparsers.add_parser("auth-test", help="Test Garmin authentication") + + # List command (Phase 2) + list_parser = subparsers.add_parser("list", help="List activities") + list_group = list_parser.add_mutually_exclusive_group(required=True) + list_group.add_argument("--all", action="store_true", help="List all activities") + list_group.add_argument("--missing", action="store_true", help="List missing activities") + list_group.add_argument("--downloaded", action="store_true", help="List downloaded activities") + + # Sync command (Phase 3) + sync_parser = subparsers.add_parser("sync", help="Sync activities and download missing FIT files") + + args = parser.parse_args() + + if args.command == "auth-test": + try: + email, _ = initialize_garmin_client() + print(f"✓ Successfully authenticated as {email}") + print("Container is ready for Phase 2 development") + exit(0) + except Exception as e: + print(f"✗ Authentication failed: {str(e)}") + exit(1) + + elif args.command == "list": + try: + client, email = initialize_garmin_client() + print("Syncing activities with Garmin Connect...") + sync_with_garmin(client, email) + + where_clause = None + if args.missing: + where_clause = "downloaded = 0" + elif args.downloaded: + where_clause = "downloaded = 1" + + activities = get_activities(where_clause) + print(f"\nFound {len(activities)} activities:") + print(f"{'ID':<10} | {'Start Time':<20} | {'Status':<10} | Filename") + print("-" * 80) + for activity in activities: + status = "✓" if activity[3] else "✗" + print(f"{activity[0]:<10} | {activity[1][:19]:<20} | {status:<10} | {activity[2]}") + + exit(0) + except Exception as e: + print(f"Operation failed: {str(e)}") + exit(1) + + elif args.command == "sync": + try: + client, email = initialize_garmin_client() + print("Syncing activities with Garmin Connect...") + sync_with_garmin(client, email) + + print("Downloading missing FIT files...") + success = download_missing_activities(client) + + if success: + print("All missing activities downloaded successfully") + exit(0) + else: + print("Some activities failed to download") + exit(1) + except Exception as e: + print(f"Operation failed: {str(e)}") + exit(1) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4063117 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +garmin-connect-export==4.6.0 +python-dotenv==1.0.1 +garth>=0.5.0,<0.6.0