migrate to garmin connect library

This commit is contained in:
2025-10-02 13:08:11 -07:00
parent c2dc64f322
commit 7d4ffcd902
10 changed files with 31445 additions and 103 deletions

View File

@@ -1,17 +1,22 @@
import os
from pathlib import Path
import garth
from garth.exc import GarthException
import asyncio
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from sqlalchemy.ext.asyncio import AsyncSession
import logging
logger = logging.getLogger(__name__)
class GarminService:
class GarminConnectService:
"""Service for interacting with Garmin Connect API."""
def __init__(self, db: Optional[AsyncSession] = None):
@@ -20,58 +25,93 @@ class GarminService:
self.password = os.getenv("GARMIN_PASSWORD")
self.session_dir = Path("data/sessions")
self.session_dir.mkdir(parents=True, exist_ok=True)
self.client: Optional[Garmin] = None
async def _get_garmin_client(self) -> Garmin:
"""Get or create a Garmin client instance."""
if self.client:
return self.client
self.client = Garmin()
return self.client
async def authenticate(self) -> bool:
"""Authenticate with Garmin Connect and persist session."""
client = await self._get_garmin_client()
try:
await asyncio.to_thread(garth.resume, self.session_dir)
logger.info("Loaded existing Garmin session")
except (FileNotFoundError, GarthException):
logger.warning("No existing session found. Attempting fresh authentication.")
logger.debug("Attempting to resume existing Garmin session.")
await asyncio.to_thread(client.login, str(self.session_dir))
logger.info("Successfully loaded existing Garmin session.")
except (FileNotFoundError, GarminConnectAuthenticationError, GarminConnectConnectionError):
logger.debug("No existing Garmin session found or session invalid.")
logger.info("Attempting fresh authentication with Garmin Connect.")
if not self.username or not self.password:
logger.error("Garmin username or password not set in environment variables.")
raise GarminAuthError("Garmin username or password not configured.")
try:
await asyncio.to_thread(garth.login, self.username, self.password)
await asyncio.to_thread(garth.save, self.session_dir)
logger.info("Successfully authenticated with Garmin Connect")
logger.debug(f"Attempting to log in with username: {self.username}")
# The login method of python-garminconnect returns (token1, token2) on successful login
# and handles MFA internally if prompt_mfa is provided.
await asyncio.to_thread(client.login, self.username, self.password)
await asyncio.to_thread(client.garth.dump, str(self.session_dir)) # Save tokens using garth.dump
logger.info("Successfully authenticated and saved new Garmin session.")
except Exception as e:
logger.error(f"Garmin authentication failed: {str(e)}")
raise GarminAuthError(f"Authentication failed: {str(e)}")
logger.error(f"Garmin fresh authentication failed: {e}", exc_info=True)
raise GarminAuthError(f"Authentication failed: {e}")
return True
async def get_activities(self, limit: int = 10, start_date: datetime = None) -> List[Dict[str, Any]]:
async def get_activities(self, limit: int = 10, start_date: Optional[datetime] = None) -> List[Dict[str, Any]]:
"""Fetch recent activities from Garmin Connect."""
await self.authenticate()
client = await self._get_garmin_client()
if not start_date:
start_date = datetime.now() - timedelta(days=7)
# Convert start_date to YYYY-MM-DD string as required by garminconnect.get_activities_by_date
start_date_str = start_date.strftime("%Y-%m-%d") if start_date else (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
end_date_str = datetime.now().strftime("%Y-%m-%d")
try:
logger.debug(f"Fetching Garmin activities with limit={limit}, start_date={start_date_str}.")
activities = await asyncio.to_thread(
garth.connectapi,
"/activity-service/activity/activities",
params={"limit": limit, "start": start_date.strftime("%Y-%m-%d")},
client.get_activities_by_date,
start_date_str,
end_date_str,
limit=limit
)
logger.info(f"Fetched {len(activities)} activities from Garmin")
logger.info(f"Successfully fetched {len(activities)} activities from Garmin.")
logger.debug(f"Garmin activities data: {activities}")
return activities or []
except (GarminConnectConnectionError, GarminConnectTooManyRequestsError) as e:
logger.error(f"Failed to fetch activities from Garmin: {e}", exc_info=True)
raise GarminAPIError(f"Failed to fetch activities: {e}")
except GarminConnectAuthenticationError as e:
logger.error(f"Garmin authentication failed while fetching activities: {e}", exc_info=True)
raise GarminAuthError(f"Authentication failed: {e}")
except Exception as e:
logger.error(f"Failed to fetch activities: {str(e)}")
raise GarminAPIError(f"Failed to fetch activities: {str(e)}")
logger.error(f"An unexpected error occurred while fetching activities from Garmin: {e}", exc_info=True)
raise GarminAPIError(f"Unexpected error: {e}")
async def get_activity_details(self, activity_id: str) -> Dict[str, Any]:
"""Get detailed activity data including metrics."""
await self.authenticate()
client = await self._get_garmin_client()
try:
logger.debug(f"Fetching detailed data for activity ID: {activity_id}.")
details = await asyncio.to_thread(
garth.connectapi, f"/activity-service/activity/{activity_id}"
client.get_activity_details, activity_id
)
logger.info(f"Fetched details for activity {activity_id}")
logger.info(f"Successfully fetched details for activity ID: {activity_id}.")
logger.debug(f"Garmin activity {activity_id} details: {details}")
return details
except (GarminConnectConnectionError, GarminConnectTooManyRequestsError) as e:
logger.error(f"Failed to fetch activity details for {activity_id}: {e}", exc_info=True)
raise GarminAPIError(f"Failed to fetch activity details: {e}")
except GarminConnectAuthenticationError as e:
logger.error(f"Garmin authentication failed while fetching activity details: {e}", exc_info=True)
raise GarminAuthError(f"Authentication failed: {e}")
except Exception as e:
logger.error(f"Failed to fetch activity details for {activity_id}: {str(e)}")
raise GarminAPIError(f"Failed to fetch activity details: {str(e)}")
logger.error(f"An unexpected error occurred while fetching activity details for {activity_id}: {e}", exc_info=True)
raise GarminAPIError(f"Unexpected error: {e}")
class GarminAuthError(Exception):

View File

@@ -1,6 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from backend.app.services.garmin import GarminService, GarminAPIError, GarminAuthError
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAPIError, GarminAuthError
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog, GarminSyncStatus
from datetime import datetime, timedelta
@@ -20,46 +20,65 @@ class WorkoutSyncService:
async def sync_recent_activities(self, days_back: int = 7) -> int:
"""Sync recent Garmin activities to database."""
logger.info(f"Starting Garmin activity sync for the last {days_back} days.")
sync_log = None # Initialize sync_log
try:
# Create sync log entry
sync_log = GarminSyncLog(status="in_progress")
sync_log = GarminSyncLog(status=GarminSyncStatus.IN_PROGRESS)
self.db.add(sync_log)
await self.db.commit()
await self.db.refresh(sync_log) # Refresh to get the generated ID
logger.debug(f"Created new GarminSyncLog with ID: {sync_log.id}")
# Calculate start date
start_date = datetime.now() - timedelta(days=days_back)
logger.debug(f"Fetching activities from Garmin starting from: {start_date}")
# Fetch activities from Garmin
activities = await self.garmin_service.get_activities(
limit=50, start_date=start_date
limit=50, start_date=start_date, end_date=datetime.now()
)
logger.debug(f"Found {len(activities)} activities from Garmin.")
synced_count = 0
for activity in activities:
activity_id = activity['activityId']
activity_id = str(activity['activityId'])
logger.debug(f"Processing activity ID: {activity_id}")
if await self.activity_exists(activity_id):
logger.debug(f"Activity {activity_id} already exists in DB, skipping.")
continue
# Get full activity details with retry logic
max_retries = 3
details = None
for attempt in range(max_retries):
try:
logger.debug(f"Attempt {attempt + 1} to fetch details for activity {activity_id}")
details = await self.garmin_service.get_activity_details(activity_id)
logger.debug(f"Successfully fetched details for activity {activity_id}.")
break
except (GarminAPIError, GarminAuthError) as e:
logger.warning(f"Failed to fetch details for {activity_id} (attempt {attempt + 1}/{max_retries}): {e}")
if attempt == max_retries - 1:
logger.error(f"Max retries reached for activity {activity_id}. Skipping details fetch.", exc_info=True)
raise
await asyncio.sleep(2 ** attempt)
logger.warning(f"Retrying activity details fetch for {activity_id}, attempt {attempt + 1}")
if details is None:
logger.warning(f"Skipping activity {activity_id} due to failure in fetching details.")
continue
# Merge basic activity data with detailed metrics
full_activity = {**activity, **details}
logger.debug(f"Merged activity data for {activity_id}.")
# Parse and create workout
workout_data = await self.parse_activity_data(full_activity)
workout = Workout(**workout_data)
self.db.add(workout)
synced_count += 1
logger.debug(f"Added workout {workout.garmin_activity_id} to session.")
# Update sync log
sync_log.status = GarminSyncStatus.COMPLETED
@@ -67,48 +86,58 @@ class WorkoutSyncService:
sync_log.last_sync_time = datetime.now()
await self.db.commit()
logger.info(f"Successfully synced {synced_count} activities")
logger.info(f"Successfully synced {synced_count} activities.")
return synced_count
except GarminAuthError as e:
sync_log.status = GarminSyncStatus.AUTH_FAILED
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Garmin authentication failed: {str(e)}")
logger.error(f"Garmin authentication failed during sync: {e}", exc_info=True)
if sync_log:
sync_log.status = GarminSyncStatus.AUTH_FAILED
sync_log.error_message = str(e)
await self.db.commit()
raise
except GarminAPIError as e:
sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Garmin API error during sync: {str(e)}")
logger.error(f"Garmin API error during sync: {e}", exc_info=True)
if sync_log:
sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e)
await self.db.commit()
raise
except Exception as e:
sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e)
await self.db.commit()
logger.error(f"Unexpected error during sync: {str(e)}")
logger.error(f"Unexpected error during Garmin sync: {e}", exc_info=True)
if sync_log:
sync_log.status = GarminSyncStatus.FAILED
sync_log.error_message = str(e)
await self.db.commit()
raise
async def get_latest_sync_status(self):
"""Get the most recent sync log entry"""
"""Get the most recent sync log entry."""
logger.debug("Fetching latest Garmin sync status.")
result = await self.db.execute(
select(GarminSyncLog)
.order_by(desc(GarminSyncLog.created_at))
.limit(1)
)
return await result.scalar_one_or_none()
status = result.scalar_one_or_none()
logger.debug(f"Latest sync status: {status.status if status else 'None'}")
return status
async def activity_exists(self, garmin_activity_id: str) -> bool:
"""Check if activity already exists in database."""
logger.debug(f"Checking if activity {garmin_activity_id} exists in database.")
result = await self.db.execute(
select(Workout).where(Workout.garmin_activity_id == garmin_activity_id)
)
return result.scalar_one_or_none() is not None # Remove the await here
exists = result.scalar_one_or_none() is not None
logger.debug(f"Activity {garmin_activity_id} exists: {exists}")
return exists
async def parse_activity_data(self, activity: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Garmin activity data into workout model format."""
logger.debug(f"Parsing activity data for Garmin activity ID: {activity.get('activityId')}")
return {
"garmin_activity_id": activity['activityId'],
"garmin_activity_id": str(activity['activityId']),
"activity_type": activity.get('activityType', {}).get('typeKey'),
"start_time": datetime.fromisoformat(activity['startTimeLocal'].replace('Z', '+00:00')),
"duration_seconds": activity.get('duration'),