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'),

View File

@@ -1,7 +1,7 @@
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.garmin import GarminService, GarminAuthError, GarminAPIError
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAuthError, GarminAPIError
from backend.app.models.garmin_sync_log import GarminSyncStatus
from datetime import datetime, timedelta
import garth # Import garth for type hinting
@@ -11,13 +11,12 @@ def mock_env_vars():
with patch.dict(os.environ, {"GARMIN_USERNAME": "test_user", "GARMIN_PASSWORD": "test_password"}):
yield
def create_garth_client_mock():
mock_client_instance = MagicMock(spec=garth.Client)
mock_client_instance.login = AsyncMock(return_value=True)
def create_garmin_client_mock():
mock_client_instance = MagicMock(spec=GarminService) # Use GarminService (which is GarminConnectService)
mock_client_instance.authenticate = AsyncMock(return_value=True)
mock_client_instance.get_activities = AsyncMock(return_value=[])
mock_client_instance.get_activity = AsyncMock(return_value={})
mock_client_instance.load = AsyncMock(side_effect=FileNotFoundError)
mock_client_instance.save = AsyncMock()
mock_client_instance.get_activity_details = AsyncMock(return_value={})
mock_client_instance.is_authenticated = MagicMock(return_value=True)
return mock_client_instance
@pytest.mark.asyncio

View File

@@ -9,7 +9,7 @@ from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from backend.app.services.garmin import GarminService, GarminAuthError, GarminAPIError
from backend.app.services.garmin import GarminConnectService as GarminService, GarminAuthError, GarminAPIError
from backend.app.services.workout_sync import WorkoutSyncService
from backend.app.models.workout import Workout
from backend.app.models.garmin_sync_log import GarminSyncLog
@@ -36,12 +36,12 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_successful_authentication(self, mock_client_class, garmin_service):
"""Test successful authentication with valid credentials."""
# Setup mock client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.login = AsyncMock(return_value=(None, None))
mock_client.save = MagicMock()
mock_client_class.return_value = mock_client
@@ -56,7 +56,7 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_failed_authentication(self, mock_client_class, garmin_service):
"""Test authentication failure with invalid credentials."""
# Setup mock client to raise exception
@@ -72,21 +72,20 @@ class TestGarminAuthentication:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_session_reuse(self, mock_client_class, garmin_service):
"""Test that existing sessions are reused."""
# Setup mock client with load method
mock_client = MagicMock()
mock_client.load = MagicMock(return_value=True)
mock_client.login = AsyncMock() # Should not be called
mock_client.login = AsyncMock(return_value=(None, None)) # Login handles loading from tokenstore
mock_client_class.return_value = mock_client
# Test authentication
result = await garmin_service.authenticate()
assert result is True
mock_client.load.assert_called_once()
mock_client.login.assert_not_awaited()
mock_client.login.assert_awaited_once_with(tokenstore=garmin_service.session_dir)
mock_client.save.assert_not_called()
class TestWorkoutSyncing:
@@ -96,7 +95,7 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_successful_sync_recent_activities(self, mock_client_class, workout_sync_service, db_session):
"""Test successful synchronization of recent activities."""
# Setup mock Garmin client
@@ -136,8 +135,8 @@ class TestWorkoutSyncing:
'elevationGain': 500.0
}
mock_client.get_activities = MagicMock(return_value=mock_activities)
mock_client.get_activity = MagicMock(return_value=mock_details)
mock_client.get_activities_by_date = MagicMock(return_value=mock_activities)
mock_client.get_activity_details = MagicMock(return_value=mock_details)
mock_client_class.return_value = mock_client
# Test sync
@@ -198,7 +197,7 @@ class TestWorkoutSyncing:
}
]
mock_client.get_activities = MagicMock(return_value=mock_activities)
mock_client.get_activities_by_date = MagicMock(return_value=mock_activities)
mock_client_class.return_value = mock_client
# Test sync
@@ -210,7 +209,7 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'invalid@example.com',
'GARMIN_PASSWORD': 'wrongpass'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_sync_with_auth_failure(self, mock_client_class, workout_sync_service, db_session):
"""Test sync failure due to authentication error."""
# Setup mock client to fail authentication
@@ -234,14 +233,14 @@ class TestWorkoutSyncing:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_sync_with_api_error(self, mock_client_class, workout_sync_service, db_session):
"""Test sync failure due to API error."""
# Setup mock client
mock_client = MagicMock()
mock_client.login = AsyncMock(return_value=True)
mock_client.save = MagicMock()
mock_client.get_activities = MagicMock(side_effect=Exception("API rate limit exceeded"))
mock_client.get_activities_by_date = MagicMock(side_effect=Exception("API rate limit exceeded"))
mock_client_class.return_value = mock_client
# Test sync
@@ -265,7 +264,7 @@ class TestErrorHandling:
'GARMIN_USERNAME': 'test@example.com',
'GARMIN_PASSWORD': 'testpass123'
})
@patch('garth.Client')
@patch('garminconnect.Garmin')
async def test_activity_detail_fetch_retry(self, mock_client_class, workout_sync_service, db_session):
"""Test retry logic when fetching activity details fails."""
# Setup mock client
@@ -283,9 +282,9 @@ class TestErrorHandling:
}
]
mock_client.get_activities = MagicMock(return_value=mock_activities)
mock_client.get_activities_by_date = MagicMock(return_value=mock_activities)
# First two calls fail, third succeeds
mock_client.get_activity = MagicMock(side_effect=[
mock_client.get_activity_details = MagicMock(side_effect=[
Exception("Temporary error"),
Exception("Temporary error"),
{
@@ -305,4 +304,4 @@ class TestErrorHandling:
assert synced_count == 1
# Verify get_activity was called 3 times (initial + 2 retries)
assert mock_client.get_activity.call_count == 3
assert mock_client.get_activity_details.call_count == 3

View File

@@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool
from sqlalchemy import select
from backend.app.database import Base
from backend.app.services.workout_sync import WorkoutSyncService
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