From 760868c98c7601698a4c9747ef118c42e54e984b Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 8 Aug 2025 12:34:33 -0700 Subject: [PATCH] python v1 --- garminsync/__init__.py | 0 garminsync/cli.py | 120 +++++++++++++++++++++++++++++++++++++++++ garminsync/config.py | 15 ++++++ garminsync/database.py | 61 +++++++++++++++++++++ garminsync/garmin.py | 42 +++++++++++++++ requirements.txt | 5 ++ 6 files changed, 243 insertions(+) create mode 100644 garminsync/__init__.py create mode 100644 garminsync/cli.py create mode 100644 garminsync/config.py create mode 100644 garminsync/database.py create mode 100644 garminsync/garmin.py create mode 100644 requirements.txt diff --git a/garminsync/__init__.py b/garminsync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/garminsync/cli.py b/garminsync/cli.py new file mode 100644 index 0000000..bcb87b2 --- /dev/null +++ b/garminsync/cli.py @@ -0,0 +1,120 @@ +import typer +from .config import load_config + +# Initialize environment variables +load_config() + +app = typer.Typer() + +@app.command(name="list") +def list_activities( + all: bool = typer.Option(False, "--all", help="List all activities"), + missing: bool = typer.Option(False, "--missing", help="List missing activities"), + downloaded: bool = typer.Option(False, "--downloaded", help="List downloaded activities") +): + """ + List activities based on specified filters + """ + from tqdm import tqdm + from .database import get_session, Activity + from .garmin import GarminClient + + # Validate input + if not any([all, missing, downloaded]): + typer.echo("Error: Please specify at least one filter option (--all, --missing, --downloaded)") + raise typer.Exit(code=1) + + client = GarminClient() + session = get_session() + + # Sync database with latest activities + typer.echo("Syncing activities from Garmin Connect...") + from .database import sync_database + sync_database(client) + + # Build query based on filters + query = session.query(Activity) + + if all: + pass # Return all activities + elif missing: + query = query.filter_by(downloaded=False) + elif downloaded: + query = query.filter_by(downloaded=True) + + # Execute query and display results + activities = query.all() + if not activities: + typer.echo("No activities found matching your criteria") + return + + # Display results with progress bar + typer.echo(f"Found {len(activities)} activities:") + for activity in tqdm(activities, desc="Listing activities"): + status = "Downloaded" if activity.downloaded else "Missing" + typer.echo(f"- ID: {activity.activity_id}, Start: {activity.start_time}, Status: {status}") + +@app.command() +def download( + missing: bool = typer.Option(False, "--missing", help="Download missing activities") +): + """ + Download activities based on specified filters + """ + from tqdm import tqdm + from pathlib import Path + from .database import get_session, Activity + from .garmin import GarminClient + + # Validate input + if not missing: + typer.echo("Error: Currently only --missing downloads are supported") + raise typer.Exit(code=1) + + client = GarminClient() + session = get_session() + + # Sync database with latest activities + typer.echo("Syncing activities from Garmin Connect...") + from .database import sync_database + sync_database(client) + + # Get missing activities + activities = session.query(Activity).filter_by(downloaded=False).all() + if not activities: + typer.echo("No missing activities found") + return + + # Create data directory if it doesn't exist + data_dir = Path(os.getenv("DATA_DIR", "data")) + data_dir.mkdir(parents=True, exist_ok=True) + + # Download activities with progress bar + typer.echo(f"Downloading {len(activities)} missing activities...") + for activity in tqdm(activities, desc="Downloading"): + try: + # Download FIT data + fit_data = client.download_activity_fit(activity.activity_id) + + # Create filename-safe timestamp + timestamp = activity.start_time.replace(":", "-").replace(" ", "_") + filename = f"activity_{activity.activity_id}_{timestamp}.fit" + filepath = data_dir / filename + + # Save file + with open(filepath, "wb") as f: + f.write(fit_data) + + # Update database + activity.filename = str(filepath) + activity.downloaded = True + session.commit() + + except Exception as e: + typer.echo(f"Error downloading activity {activity.activity_id}: {str(e)}") + session.rollback() + + typer.echo("Download completed successfully") + +if __name__ == "__main__": + app() diff --git a/garminsync/config.py b/garminsync/config.py new file mode 100644 index 0000000..5c2447e --- /dev/null +++ b/garminsync/config.py @@ -0,0 +1,15 @@ +from dotenv import load_dotenv +import os + +def load_config(): + """Load environment variables from .env file""" + load_dotenv() + +class Config: + GARMIN_EMAIL = os.getenv("GARMIN_EMAIL") + GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD") + + @classmethod + def validate(cls): + if not cls.GARMIN_EMAIL or not cls.GARMIN_PASSWORD: + raise ValueError("Missing GARMIN_EMAIL or GARMIN_PASSWORD in environment") diff --git a/garminsync/database.py b/garminsync/database.py new file mode 100644 index 0000000..f270678 --- /dev/null +++ b/garminsync/database.py @@ -0,0 +1,61 @@ +import os +from sqlalchemy import create_engine, Column, Integer, String, Boolean +from sqlalchemy.orm import declarative_base, sessionmaker +from sqlalchemy.exc import SQLAlchemyError + +Base = declarative_base() + +class Activity(Base): + __tablename__ = 'activities' + + activity_id = Column(Integer, primary_key=True) + start_time = Column(String, nullable=False) + filename = Column(String, unique=True, nullable=True) + downloaded = Column(Boolean, default=False, nullable=False) + +def init_db(): + """Initialize database connection and create tables""" + db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db") + engine = create_engine(f"sqlite:///{db_path}") + Base.metadata.create_all(engine) + return engine + +def get_session(): + """Create a new database session""" + engine = init_db() + Session = sessionmaker(bind=engine) + return Session() + +def sync_database(garmin_client): + """Sync local database with Garmin Connect activities""" + session = get_session() + try: + # Fetch activities from Garmin Connect + activities = garmin_client.get_activities(0, 1000) + + # Process activities and update database + for activity in activities: + activity_id = activity["activityId"] + start_time = activity["startTimeLocal"] + + # Check if activity exists in database + existing = session.query(Activity).filter_by(activity_id=activity_id).first() + if not existing: + new_activity = Activity( + activity_id=activity_id, + start_time=start_time, + downloaded=False + ) + session.add(new_activity) + + session.commit() + except SQLAlchemyError as e: + session.rollback() + raise e + finally: + session.close() + +# Example usage: +# from .garmin import GarminClient +# client = GarminClient() +# sync_database(client) diff --git a/garminsync/garmin.py b/garminsync/garmin.py new file mode 100644 index 0000000..ef15c80 --- /dev/null +++ b/garminsync/garmin.py @@ -0,0 +1,42 @@ +import os +import time +from garminconnect import Garmin + +class GarminClient: + def __init__(self): + self.client = None + + def authenticate(self): + """Authenticate using credentials from environment variables""" + email = os.getenv("GARMIN_EMAIL") + password = os.getenv("GARMIN_PASSWORD") + + if not email or not password: + raise ValueError("Garmin credentials not found in environment variables") + + self.client = Garmin(email, password) + self.client.login() + return self.client + + def get_activities(self, start=0, limit=10): + """Get list of activities with rate limiting""" + if not self.client: + self.authenticate() + + activities = self.client.get_activities(start, limit) + time.sleep(2) # Rate limiting + return activities + + def download_activity_fit(self, activity_id): + """Download .fit file for a specific activity""" + if not self.client: + self.authenticate() + + fit_data = self.client.download_activity(activity_id, dl_fmt='fit') + time.sleep(2) # Rate limiting + return fit_data + +# Example usage: +# client = GarminClient() +# activities = client.get_activities(0, 10) +# fit_data = client.download_activity_fit(12345) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7903913 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +typer>=0.12.3,<0.13.0 +python-dotenv==1.0.0 +garminconnect==0.2.28 +sqlalchemy==2.0.23 +tqdm==4.66.1