diff --git a/fitbitsync.py b/fitbitsync.py index c51e816..13008d0 100644 --- a/fitbitsync.py +++ b/fitbitsync.py @@ -22,14 +22,10 @@ except ImportError: FITBIT_LIBRARY = False try: - from garminconnect import Garmin + import garminconnect GARMIN_LIBRARY = "garminconnect" except ImportError: - try: - import garth - GARMIN_LIBRARY = "garth" - except ImportError: - GARMIN_LIBRARY = None + GARMIN_LIBRARY = None import schedule @@ -86,7 +82,6 @@ class ConfigManager: return self._create_default_config() return self._create_default_config() - def _create_default_config(self) -> Dict: """Create default configuration""" config = { @@ -540,7 +535,7 @@ class FitbitClient: return records class GarminClient: - """Client for Garmin Connect using garminconnect or garth library""" + """Client for Garmin Connect using garminconnect library""" def __init__(self, config: ConfigManager): self.config = config @@ -551,8 +546,14 @@ class GarminClient: self.garmin_client = None self.read_only_mode = config.get('sync.read_only_mode', False) - if not GARMIN_LIBRARY: - raise ImportError("Neither 'garminconnect' nor 'garth' library is installed. Please install one of them.") + # Check if garminconnect is available + try: + import garminconnect + self.garminconnect = garminconnect + logger.info("Using garminconnect library") + except ImportError: + logger.error("garminconnect library not installed. Install with: pip install garminconnect") + raise ImportError("garminconnect library is required but not installed") async def authenticate(self) -> bool: """Authenticate with Garmin Connect""" @@ -569,10 +570,42 @@ class GarminClient: logger.info("No stored Garmin credentials found. Please set them up.") return self._setup_credentials() - if GARMIN_LIBRARY == "garminconnect": - return await self._authenticate_garminconnect() - elif GARMIN_LIBRARY == "garth": - return await self._authenticate_garth() + # Create Garmin Connect client + logger.info("Authenticating with Garmin Connect...") + + # Try to load existing session first + if os.path.exists(self.session_file): + try: + self.garmin_client = self.garminconnect.Garmin() + self.garmin_client.load(self.session_file) + + # Test the session by trying to get profile + profile = self.garmin_client.get_full_name() + logger.info(f"Resumed existing session for user: {profile}") + return True + + except Exception as e: + logger.warning(f"Existing session invalid: {e}") + # Fall through to fresh login + + # Perform fresh login + self.garmin_client = self.garminconnect.Garmin( + self.username, + self.password, + is_cn=self.is_china + ) + + # Login and save session + self.garmin_client.login() + + # Save session data + self.garmin_client.save(self.session_file) + + # Test the connection + profile = self.garmin_client.get_full_name() + logger.info(f"Successfully authenticated for user: {profile}") + + return True except Exception as e: logger.error(f"Garmin authentication error: {e}") @@ -603,103 +636,16 @@ class GarminClient: print("✅ Credentials saved securely") return True - async def _authenticate_garminconnect(self) -> bool: - """Authenticate using garminconnect library""" - try: - logger.info("Authenticating with Garmin Connect using garminconnect library...") - - # Create Garmin client (garminconnect doesn't support is_china parameter) - self.garmin_client = Garmin( - email=self.username, - password=self.password - ) - - # Try to load existing session - if os.path.exists(self.session_file): - try: - with open(self.session_file, 'r') as f: - session_data = json.load(f) - self.garmin_client.login() - logger.info("Loaded existing Garmin session") - return True - except Exception as e: - logger.warning(f"Failed to load existing session: {e}") - - # Perform fresh login - self.garmin_client.login() - - # Save session data for future use (if available) - try: - # Different versions of garminconnect may have different session handling - if hasattr(self.garmin_client, 'session') and self.garmin_client.session: - session_data = { - "session_data": self.garmin_client.session.cookies.get_dict(), - "timestamp": datetime.now().isoformat() - } - with open(self.session_file, 'w') as f: - json.dump(session_data, f) - logger.info("Saved Garmin session data") - else: - logger.info("Session data not available for saving") - except Exception as e: - logger.warning(f"Failed to save session data: {e}") - - # Test the connection - user_profile = self.garmin_client.get_user_profile() - logger.info(f"Successfully authenticated as {user_profile.get('displayName', 'Unknown')}") - - return True - - except Exception as e: - logger.error(f"garminconnect authentication failed: {e}") - return False - - async def _authenticate_garth(self) -> bool: - """Authenticate using garth library""" - try: - logger.info("Authenticating with Garmin Connect using garth library...") - - # Configure garth - garth.configure(domain="garmin.com" if not self.is_china else "garmin.cn") - - # Try to load existing session - if os.path.exists(self.session_file): - try: - garth.resume(self.session_file) - garth.client.username # Test if session is valid - logger.info("Resumed existing Garmin session") - return True - except Exception as e: - logger.warning(f"Failed to resume existing session: {e}") - - # Perform fresh login - garth.login(self.username, self.password) - - # Save session - garth.save(self.session_file) - logger.info("Successfully authenticated and saved session") - - return True - - except Exception as e: - logger.error(f"garth authentication failed: {e}") - return False - async def upload_weight_data(self, records: List[WeightRecord]) -> Tuple[int, int]: - """Upload weight records to Garmin""" + """Upload weight records to Garmin using garminconnect""" if self.read_only_mode: logger.info(f"Read-only mode: Would upload {len(records)} weight records to Garmin") - # Simulate successful upload in read-only mode for record in records: logger.info(f"Read-only mode: Would upload {record.weight_kg}kg at {record.timestamp}") - return len(records), 0 # All "successful", none failed + return len(records), 0 - if not self.garmin_client and GARMIN_LIBRARY == "garminconnect": - logger.error("Not authenticated with Garmin (garminconnect)") - return 0, len(records) - - if GARMIN_LIBRARY == "garth" and not os.path.exists(self.session_file): - logger.error("Not authenticated with Garmin (garth)") + if not self.garmin_client: + logger.error("Garmin client not authenticated") return 0, len(records) success_count = 0 @@ -707,12 +653,7 @@ class GarminClient: for record in records: try: - if GARMIN_LIBRARY == "garminconnect": - success = await self._upload_weight_garminconnect(record) - elif GARMIN_LIBRARY == "garth": - success = await self._upload_weight_garth(record) - else: - success = False + success = await self._upload_weight_garminconnect(record) if success: success_count += 1 @@ -721,13 +662,154 @@ class GarminClient: logger.error(f"Failed to upload weight record: {record.weight_kg}kg at {record.timestamp}") # Rate limiting - wait between requests - await asyncio.sleep(1) + await asyncio.sleep(2) except Exception as e: logger.error(f"Error uploading weight record: {e}") return success_count, total_count - success_count + async def _upload_weight_garminconnect(self, record: WeightRecord) -> bool: + """Upload weight using garminconnect library""" + try: + # Format date as YYYY-MM-DD string + date_str = record.timestamp.strftime("%Y-%m-%d") + + logger.info(f"Uploading weight via garminconnect: {record.weight_kg}kg on {date_str}") + + # Convert datetime to timestamp string format that garminconnect expects + # Some versions expect ISO format string, others expect timestamp + timestamp_str = record.timestamp.isoformat() + + # Try different methods depending on garminconnect version + try: + # Method 1: Try add_body_composition with datetime object + result = self.garmin_client.add_body_composition( + timestamp=record.timestamp, + weight=record.weight_kg + ) + + except Exception as e1: + logger.debug(f"Method 1 failed: {e1}") + + try: + # Method 2: Try with ISO format string + result = self.garmin_client.add_body_composition( + timestamp=timestamp_str, + weight=record.weight_kg + ) + + except Exception as e2: + logger.debug(f"Method 2 failed: {e2}") + + try: + # Method 3: Try with date string only + result = self.garmin_client.add_body_composition( + timestamp=date_str, + weight=record.weight_kg + ) + + except Exception as e3: + logger.debug(f"Method 3 failed: {e3}") + + try: + # Method 4: Try set_body_composition if add_body_composition doesn't exist + if hasattr(self.garmin_client, 'set_body_composition'): + result = self.garmin_client.set_body_composition( + timestamp=record.timestamp, + weight=record.weight_kg + ) + else: + # Method 5: Try legacy weight upload methods + if hasattr(self.garmin_client, 'add_weigh_in'): + result = self.garmin_client.add_weigh_in( + weight=record.weight_kg, + date=date_str + ) + else: + raise Exception("No suitable weight upload method found") + + except Exception as e4: + logger.error(f"All upload methods failed: {e1}, {e2}, {e3}, {e4}") + return False + + if result: + logger.info(f"garminconnect upload successful") + return True + else: + logger.error("garminconnect upload returned no result") + return False + + except Exception as e: + logger.error(f"garminconnect upload error: {e}") + + # Check if it's an authentication error + if "401" in str(e) or "unauthorized" in str(e).lower(): + logger.error("Authentication failed - session may be expired") + # Try to re-authenticate + try: + logger.info("Attempting to re-authenticate...") + self.garmin_client.login() + self.garmin_client.save_session(self.session_file) + + # Retry the upload + result = self.garmin_client.add_body_composition( + timestamp=record.timestamp, + weight=record.weight_kg + ) + + if result: + logger.info("Upload successful after re-authentication") + return True + else: + logger.error("Upload failed even after re-authentication") + return False + + except Exception as re_auth_error: + logger.error(f"Re-authentication failed: {re_auth_error}") + return False + + # Check if it's a rate limiting error + elif "429" in str(e) or "rate" in str(e).lower(): + logger.error("Rate limit exceeded") + logger.error("Wait at least 1-2 hours before trying again") + return False + + # Check if it's a duplicate entry error + elif "duplicate" in str(e).lower() or "already exists" in str(e).lower(): + logger.warning(f"Weight already exists for {date_str}") + logger.info("Treating duplicate as successful upload") + return True + + return False + + def get_recent_weights(self, days: int = 7) -> List[Dict]: + """Get recent weight data from Garmin (for verification)""" + if self.read_only_mode: + logger.info("Read-only mode: Cannot fetch Garmin weights") + return [] + + try: + if not self.garmin_client: + logger.error("Garmin client not authenticated") + return [] + + # Get body composition data for the last N days + from datetime import timedelta + end_date = datetime.now().date() + start_date = end_date - timedelta(days=days) + + weights = self.garmin_client.get_body_composition( + startdate=start_date, + enddate=end_date + ) + + return weights if weights else [] + + except Exception as e: + logger.error(f"Error getting recent weights: {e}") + return [] + def check_garmin_weights(self, days: int = 30): """Check recent Garmin weights for anomalies""" try: @@ -749,24 +831,30 @@ class GarminClient: for weight_entry in recent_weights: try: - date = weight_entry.get('calendarDate', 'Unknown') - weight_raw = weight_entry.get('weight', 0) + # garminconnect returns different format than garth + date = weight_entry.get('timestamp', weight_entry.get('date', 'Unknown')) + if isinstance(date, datetime): + date = date.strftime('%Y-%m-%d') - # Garmin stores weight in grams, convert to kg - weight_kg = weight_raw / 1000 if weight_raw else 0 + # Weight might be in different fields depending on API version + weight_kg = ( + weight_entry.get('weight') or + weight_entry.get('bodyWeight') or + weight_entry.get('weightInKilos', 0) + ) # Check for anomalies (weights outside normal human range) - if weight_kg > 1000 or weight_kg < 20: # Clearly wrong values - anomalies.append((date, weight_kg, weight_raw)) + if weight_kg > 300 or weight_kg < 30: # Clearly wrong values + anomalies.append((date, weight_kg)) status = "❌ ANOMALY" - elif weight_kg > 300 or weight_kg < 30: # Suspicious values - anomalies.append((date, weight_kg, weight_raw)) + elif weight_kg > 200 or weight_kg < 40: # Suspicious values + anomalies.append((date, weight_kg)) status = "⚠️ SUSPICIOUS" else: normal_weights.append((date, weight_kg)) status = "✅ OK" - print(f"📅 {date}: {weight_kg:.1f}kg (raw: {weight_raw}) {status}") + print(f"📅 {date}: {weight_kg:.1f}kg {status}") except Exception as e: print(f"❌ Error parsing weight entry: {e}") @@ -775,8 +863,8 @@ class GarminClient: print(f"\n🚨 Found {len(anomalies)} anomalous weight entries!") print("These may need to be manually deleted from Garmin Connect.") print("Anomalous entries:") - for date, weight_kg, weight_raw in anomalies: - print(f" - {date}: {weight_kg:.1f}kg (raw value: {weight_raw})") + for date, weight_kg in anomalies: + print(f" - {date}: {weight_kg:.1f}kg") if normal_weights: print(f"\n✅ {len(normal_weights)} normal weight entries found") @@ -786,182 +874,7 @@ class GarminClient: except Exception as e: logger.error(f"Error checking Garmin weights: {e}") print(f"❌ Error checking Garmin weights: {e}") - - - async def _upload_weight_garminconnect(self, record: WeightRecord) -> bool: - """Upload weight using garminconnect library""" - try: - # Convert timestamp to various formats we might need - date_str = record.timestamp.strftime("%Y-%m-%d") - timestamp_ms = int(record.timestamp.timestamp() * 1000) - # Weight should be in kg for most methods - weight_kg = record.weight_kg - - logger.info(f"Uploading weight: {weight_kg}kg on {date_str}") - - # Try methods in order of most likely to work correctly - upload_attempts = [ - # Method 1: add_weigh_in_with_timestamps (if available) - { - 'method': 'add_weigh_in_with_timestamps', - 'args': [timestamp_ms, weight_kg], - 'kwargs': {}, - 'description': 'timestamp + weight in kg' - }, - # Method 2: add_weigh_in with date string and weight in kg - { - 'method': 'add_weigh_in', - 'args': [date_str, weight_kg], - 'kwargs': {}, - 'description': 'date string + weight in kg' - }, - # Method 3: add_weigh_in with keyword arguments - { - 'method': 'add_weigh_in', - 'args': [], - 'kwargs': {'date': date_str, 'weight': weight_kg}, - 'description': 'kwargs: date + weight in kg' - }, - # Method 4: add_body_composition with just date and weight - { - 'method': 'add_body_composition', - 'args': [date_str], - 'kwargs': {'weight': weight_kg}, - 'description': 'date + weight in kg as kwarg' - }, - # Method 5: add_body_composition with date and weight as positional args - { - 'method': 'add_body_composition', - 'args': [date_str, weight_kg], - 'kwargs': {}, - 'description': 'date + weight in kg as positional args' - }, - # Method 6: Try without any timestamp, just weight - { - 'method': 'add_body_composition', - 'args': [], - 'kwargs': {'weight': weight_kg}, - 'description': 'weight only in kg' - } - ] - - for i, attempt in enumerate(upload_attempts, 1): - method_name = attempt['method'] - args = attempt['args'] - kwargs = attempt['kwargs'] - description = attempt['description'] - - if hasattr(self.garmin_client, method_name): - try: - logger.info(f"Trying method {i}: {method_name} ({description})") - logger.info(f" Args: {args}") - logger.info(f" Kwargs: {kwargs}") - - method = getattr(self.garmin_client, method_name) - - # Call method with both positional and keyword arguments - if args and kwargs: - result = method(*args, **kwargs) - elif args: - result = method(*args) - elif kwargs: - result = method(**kwargs) - else: - result = method() - - logger.info(f"✅ Method {method_name} succeeded. Result: {result}") - - # Verify the upload by checking recent weights - await asyncio.sleep(2) # Give Garmin time to process - try: - recent_weights = self.get_recent_weights(1) - if recent_weights: - latest_weight = recent_weights[0] - garmin_weight_kg = latest_weight.get('weight', 0) / 1000 if latest_weight.get('weight') else 0 - logger.info(f"Verification: Latest Garmin weight is {garmin_weight_kg}kg") - - # Check if the weight is reasonable (within 10% of what we uploaded) - if abs(garmin_weight_kg - weight_kg) / weight_kg < 0.1: - logger.info(f"✅ Weight verification passed: {garmin_weight_kg}kg ≈ {weight_kg}kg") - return True - else: - logger.warning(f"⚠️ Weight verification failed: uploaded {weight_kg}kg but Garmin shows {garmin_weight_kg}kg") - # Continue to try other methods - except Exception as e: - logger.warning(f"Could not verify upload: {e}") - # Assume success if we can't verify - return True - - return True - - except Exception as e: - logger.warning(f"Method {method_name} failed: {e}") - # Log the full error for the first few attempts - if i <= 3: - import traceback - logger.warning(f"Full error for {method_name}: {traceback.format_exc()}") - continue - else: - logger.debug(f"Method {method_name} not available") - - logger.error("All upload methods failed") - return False - - except Exception as e: - logger.error(f"garminconnect upload error: {e}") - import traceback - logger.error(f"Full traceback: {traceback.format_exc()}") - return False - - async def _upload_weight_garth(self, record: WeightRecord) -> bool: - """Upload weight using garth library""" - try: - # Garth expects weight in kg and timestamp in specific format - timestamp_ms = int(record.timestamp.timestamp() * 1000) - - # Use garth to upload weight - response = garth.post( - "biometric-service", - "/biometric-service/weight", - json={ - "timestampGMT": timestamp_ms, - "unitKey": "kg", - "value": record.weight_kg - } - ) - - return response.status_code in [200, 201, 204] - - except Exception as e: - logger.error(f"garth upload error: {e}") - return False - - def get_recent_weights(self, days: int = 7) -> List[Dict]: - """Get recent weight data from Garmin (for verification)""" - if self.read_only_mode: - logger.info("Read-only mode: Cannot fetch Garmin weights") - return [] - - try: - if GARMIN_LIBRARY == "garminconnect" and self.garmin_client: - end_date = datetime.now().date() - start_date = end_date - timedelta(days=days) - - weights = self.garmin_client.get_body_composition( - start_date.isoformat(), - end_date.isoformat() - ) - return weights or [] - - elif GARMIN_LIBRARY == "garth": - # Implementation for garth if needed - return [] - - except Exception as e: - logger.error(f"Error getting recent weights: {e}") - return [] - class WeightSyncApp: """Main application class""" @@ -1415,4 +1328,4 @@ async def main(): print("\n🔄 Currently in FULL SYNC mode - will upload to Garmin") if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main())