mirror of
https://github.com/sstent/GarminSync.git
synced 2026-01-26 00:52:32 +00:00
python
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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
|
||||
85
Design.md
Normal file
85
Design.md
Normal file
@@ -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
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -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"]
|
||||
235
main.py
Normal file
235
main.py
Normal file
@@ -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()
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
garmin-connect-export==4.6.0
|
||||
python-dotenv==1.0.1
|
||||
garth>=0.5.0,<0.6.0
|
||||
Reference in New Issue
Block a user