# Fitbit to Garmin Weight Sync Application # Syncs weight data from Fitbit API to Garmin Connect import asyncio import json import logging import sqlite3 from datetime import datetime, timedelta, timezone from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, asdict from pathlib import Path import hashlib import time import os import webbrowser from urllib.parse import urlparse, parse_qs try: import fitbit FITBIT_LIBRARY = True except ImportError: FITBIT_LIBRARY = False try: from garminconnect import Garmin GARMIN_LIBRARY = "garminconnect" except ImportError: try: import garth GARMIN_LIBRARY = "garth" except ImportError: GARMIN_LIBRARY = None import schedule # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('weight_sync.log'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) @dataclass class WeightRecord: """Represents a weight measurement""" timestamp: datetime weight_kg: float source: str = "fitbit" sync_id: Optional[str] = None def __post_init__(self): if self.sync_id is None: # Create unique ID based on timestamp and weight unique_string = f"{self.timestamp.isoformat()}_{self.weight_kg}" self.sync_id = hashlib.md5(unique_string.encode()).hexdigest() class ConfigManager: """Manages application configuration and credentials""" def __init__(self, config_file: str = "config.json"): self.config_file = Path(config_file) self.config = self._load_config() def _load_config(self) -> Dict: """Load configuration from file""" if self.config_file.exists(): try: with open(self.config_file, 'r') as f: config = json.load(f) # Ensure all required sections exist default_config = self._create_default_config() for section, defaults in default_config.items(): if section not in config: config[section] = defaults elif isinstance(defaults, dict): for key, default_value in defaults.items(): if key not in config[section]: config[section][key] = default_value return config except Exception as e: logger.warning(f"Error loading config file: {e}") return self._create_default_config() return self._create_default_config() def _create_default_config(self) -> Dict: """Create default configuration""" config = { "fitbit": { "client_id": "", "client_secret": "", "access_token": "", "refresh_token": "", "token_file": "fitbit_token.json", "redirect_uri": "http://localhost:8080/fitbit-callback" }, "garmin": { "username": "", "password": "", "is_china": False, # Set to True if using Garmin China "session_data_file": "garmin_session.json" }, "sync": { "sync_interval_minutes": 60, "lookback_days": 7, "max_retries": 3, "read_only_mode": False # Set to True to prevent uploads to Garmin }, "database": { "path": "weight_sync.db" } } # Don't automatically save here, let the caller decide return config def save_config(self, config: Dict = None): """Save configuration to file""" if config: self.config = config with open(self.config_file, 'w') as f: json.dump(self.config, f, indent=2) def get(self, key: str, default=None): """Get configuration value using dot notation""" keys = key.split('.') value = self.config for k in keys: value = value.get(k, {}) return value if value != {} else default def set_credentials(self, service: str, **kwargs): """Store credentials in config file""" if service == "garmin": # Ensure garmin section exists if "garmin" not in self.config: self.config["garmin"] = {} self.config["garmin"]["username"] = kwargs.get("username", "") self.config["garmin"]["password"] = kwargs.get("password", "") elif service == "fitbit": # Ensure fitbit section exists if "fitbit" not in self.config: self.config["fitbit"] = { "client_id": "", "client_secret": "", "access_token": "", "refresh_token": "", "token_file": "fitbit_token.json", "redirect_uri": "http://localhost:8080/fitbit-callback" } for key, value in kwargs.items(): if key in self.config["fitbit"]: self.config["fitbit"][key] = value self.save_config() def get_credentials(self, service: str, field: str) -> Optional[str]: """Retrieve stored credentials from config""" if service == "garmin": return self.config.get("garmin", {}).get(field) elif service == "fitbit": return self.config.get("fitbit", {}).get(field) class DatabaseManager: """Manages SQLite database for sync state and records""" def __init__(self, db_path: str): self.db_path = db_path self._init_database() def _init_database(self): """Initialize database tables""" with sqlite3.connect(self.db_path) as conn: conn.execute(''' CREATE TABLE IF NOT EXISTS weight_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, sync_id TEXT UNIQUE NOT NULL, timestamp TEXT NOT NULL, weight_kg REAL NOT NULL, source TEXT NOT NULL, synced_to_garmin BOOLEAN DEFAULT FALSE, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') conn.execute(''' CREATE TABLE IF NOT EXISTS sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, sync_type TEXT NOT NULL, status TEXT NOT NULL, message TEXT, records_processed INTEGER DEFAULT 0, timestamp TEXT DEFAULT CURRENT_TIMESTAMP ) ''') # Create indexes separately conn.execute('CREATE INDEX IF NOT EXISTS idx_weight_timestamp ON weight_records(timestamp)') conn.execute('CREATE INDEX IF NOT EXISTS idx_weight_sync_id ON weight_records(sync_id)') conn.execute('CREATE INDEX IF NOT EXISTS idx_sync_log_timestamp ON sync_log(timestamp)') def save_weight_record(self, record: WeightRecord) -> bool: """Save weight record to database""" try: with sqlite3.connect(self.db_path) as conn: conn.execute(''' INSERT OR REPLACE INTO weight_records (sync_id, timestamp, weight_kg, source, updated_at) VALUES (?, ?, ?, ?, ?) ''', ( record.sync_id, record.timestamp.isoformat(), record.weight_kg, record.source, datetime.now().isoformat() )) return True except Exception as e: logger.error(f"Error saving weight record: {e}") return False def get_unsynced_records(self) -> List[WeightRecord]: """Get records that haven't been synced to Garmin""" records = [] try: with sqlite3.connect(self.db_path) as conn: cursor = conn.execute(''' SELECT sync_id, timestamp, weight_kg, source FROM weight_records WHERE synced_to_garmin = FALSE ORDER BY timestamp DESC ''') for row in cursor.fetchall(): record = WeightRecord( sync_id=row[0], timestamp=datetime.fromisoformat(row[1]), weight_kg=row[2], source=row[3] ) records.append(record) except Exception as e: logger.error(f"Error getting unsynced records: {e}") return records def mark_synced(self, sync_id: str) -> bool: """Mark a record as synced to Garmin""" try: with sqlite3.connect(self.db_path) as conn: conn.execute(''' UPDATE weight_records SET synced_to_garmin = TRUE, updated_at = ? WHERE sync_id = ? ''', (datetime.now().isoformat(), sync_id)) return True except Exception as e: logger.error(f"Error marking record as synced: {e}") return False def log_sync(self, sync_type: str, status: str, message: str = "", records_processed: int = 0): """Log sync operation""" try: with sqlite3.connect(self.db_path) as conn: conn.execute(''' INSERT INTO sync_log (sync_type, status, message, records_processed) VALUES (?, ?, ?, ?) ''', (sync_type, status, message, records_processed)) except Exception as e: logger.error(f"Error logging sync: {e}") class FitbitClient: """Client for Fitbit API using python-fitbit""" def __init__(self, config: ConfigManager): self.config = config self.client = None if not FITBIT_LIBRARY: raise ImportError("python-fitbit library is not installed. Please install it with: pip install fitbit") # Test if we can import the required modules try: import fitbit from fitbit.api import FitbitOauth2Client except ImportError as e: logger.error(f"Failed to import required fitbit modules: {e}") raise ImportError(f"Fitbit library import failed: {e}. Please reinstall with: pip install fitbit") async def authenticate(self) -> bool: """Authenticate with Fitbit API""" try: client_id = self.config.get_credentials('fitbit', 'client_id') client_secret = self.config.get_credentials('fitbit', 'client_secret') if not client_id or not client_secret: logger.info("No Fitbit credentials found. Please set them up.") if not self._setup_credentials(): return False # Reload credentials after setup client_id = self.config.get_credentials('fitbit', 'client_id') client_secret = self.config.get_credentials('fitbit', 'client_secret') # Try to load existing tokens access_token = self.config.get_credentials('fitbit', 'access_token') refresh_token = self.config.get_credentials('fitbit', 'refresh_token') if access_token and refresh_token: # Try to use existing tokens try: self.client = fitbit.Fitbit( client_id, client_secret, access_token=access_token, refresh_token=refresh_token, refresh_cb=self._token_refresh_callback ) # Test the connection profile = self.client.user_profile_get() logger.info(f"Successfully authenticated with existing tokens for user: {profile['user']['displayName']}") return True except Exception as e: logger.warning(f"Existing tokens invalid: {e}") # Clear invalid tokens self.config.set_credentials('fitbit', access_token="", refresh_token="") # Fall through to OAuth flow # Perform OAuth flow return await self._oauth_flow(client_id, client_secret) except Exception as e: logger.error(f"Fitbit authentication error: {e}") import traceback logger.error(f"Full error traceback: {traceback.format_exc()}") return False def _setup_credentials(self) -> bool: """Setup Fitbit credentials interactively""" print("\nšŸ”‘ Fitbit API Credentials Setup") print("=" * 40) print("To get your Fitbit API credentials:") print("1. Go to https://dev.fitbit.com/apps") print("2. Create a new app or use an existing one") print("3. Copy the Client ID and Client Secret") print("4. Set OAuth 2.0 Application Type to 'Personal'") print("5. Set Callback URL to: http://localhost:8080/fitbit-callback") print() client_id = input("Enter your Fitbit Client ID: ").strip() if not client_id: print("āŒ Client ID cannot be empty") return False import getpass client_secret = getpass.getpass("Enter your Fitbit Client Secret: ").strip() if not client_secret: print("āŒ Client Secret cannot be empty") return False # Store credentials self.config.set_credentials('fitbit', client_id=client_id, client_secret=client_secret) print("āœ… Credentials saved") return True async def _oauth_flow(self, client_id: str, client_secret: str) -> bool: """Perform OAuth 2.0 authorization flow""" try: redirect_uri = self.config.get('fitbit.redirect_uri') # Create Fitbit client for OAuth from fitbit.api import FitbitOauth2Client auth_client = FitbitOauth2Client( client_id, client_secret, redirect_uri=redirect_uri ) # Get authorization URL auth_url, _ = auth_client.authorize_token_url() print("\nšŸ” Fitbit OAuth Authorization") print("=" * 40) print("Opening your browser for Fitbit authorization...") print(f"If it doesn't open automatically, visit: {auth_url}") print("\nAfter authorizing the app, you'll be redirected to a page that may show an error.") print("That's normal! Just copy the FULL URL from your browser's address bar.") print() # Open browser try: webbrowser.open(auth_url) except Exception as e: logger.warning(f"Could not open browser: {e}") # Get the callback URL from user callback_url = input("After authorization, paste the full callback URL here: ").strip() if not callback_url: print("āŒ Callback URL cannot be empty") return False # Extract authorization code from callback URL parsed_url = urlparse(callback_url) query_params = parse_qs(parsed_url.query) if 'code' not in query_params: print("āŒ No authorization code found in callback URL") print(f"URL received: {callback_url}") print("Make sure you copied the complete URL after authorization") return False auth_code = query_params['code'][0] # Exchange code for tokens token = auth_client.fetch_access_token(auth_code) # Save tokens self.config.set_credentials( 'fitbit', access_token=token['access_token'], refresh_token=token['refresh_token'] ) # Create authenticated client self.client = fitbit.Fitbit( client_id, client_secret, access_token=token['access_token'], refresh_token=token['refresh_token'], refresh_cb=self._token_refresh_callback ) # Test the connection profile = self.client.user_profile_get() print(f"āœ… Successfully authenticated for user: {profile['user']['displayName']}") logger.info(f"Successfully authenticated for user: {profile['user']['displayName']}") return True except Exception as e: logger.error(f"OAuth flow failed: {e}") import traceback logger.error(f"Full error traceback: {traceback.format_exc()}") print(f"āŒ OAuth authentication failed: {e}") return False def _token_refresh_callback(self, token): """Callback for when tokens are refreshed""" logger.info("Fitbit tokens refreshed") self.config.set_credentials( 'fitbit', access_token=token['access_token'], refresh_token=token['refresh_token'] ) async def get_weight_data(self, start_date: datetime, end_date: datetime) -> List[WeightRecord]: """Fetch weight data from Fitbit API""" if not self.client: logger.error("Fitbit client not authenticated") return [] logger.info(f"Fetching weight data from Fitbit API from {start_date.date()} to {end_date.date()}") records = [] try: # Fitbit API expects dates in YYYY-mm-dd format start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") # Get weight data from Fitbit weight_data = self.client.get_bodyweight( base_date=start_date_str, end_date=end_date_str ) logger.info(f"Raw Fitbit API response keys: {list(weight_data.keys()) if weight_data else 'None'}") # Parse weight data - handle both possible response formats weight_entries = None if weight_data: # Try the format from your actual API response if 'weight' in weight_data: weight_entries = weight_data['weight'] logger.info(f"Found weight data in 'weight' key") # Try the format the original code expected elif 'body-weight' in weight_data: weight_entries = weight_data['body-weight'] logger.info(f"Found weight data in 'body-weight' key") else: logger.warning(f"Unexpected API response format. Keys: {list(weight_data.keys())}") if weight_entries: logger.info(f"Processing {len(weight_entries)} weight entries") for weight_entry in weight_entries: try: # Parse date and time date_str = weight_entry['date'] time_str = weight_entry.get('time', '00:00:00') # Combine date and time datetime_str = f"{date_str} {time_str}" timestamp = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S") timestamp = timestamp.replace(tzinfo=timezone.utc) # Get weight - the API returns weight in pounds, need to convert to kg weight_lbs = float(weight_entry['weight']) weight_kg = weight_lbs * 0.453592 # Convert pounds to kg record = WeightRecord( timestamp=timestamp, weight_kg=weight_kg, source="fitbit" ) records.append(record) logger.info(f"Found weight record: {weight_lbs}lbs ({weight_kg:.1f}kg) at {timestamp}") except Exception as e: logger.warning(f"Failed to parse weight entry {weight_entry}: {e}") continue else: logger.info("No weight entries found in API response") logger.info(f"Retrieved {len(records)} weight records from Fitbit") except Exception as e: logger.error(f"Error fetching Fitbit weight data: {e}") import traceback logger.error(f"Full traceback: {traceback.format_exc()}") return records class GarminClient: """Client for Garmin Connect using garminconnect or garth library""" def __init__(self, config: ConfigManager): self.config = config self.username = None self.password = None self.is_china = config.get('garmin.is_china', False) self.session_file = config.get('garmin.session_data_file', 'garmin_session.json') 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.") async def authenticate(self) -> bool: """Authenticate with Garmin Connect""" if self.read_only_mode: logger.info("Running in read-only mode - skipping Garmin authentication") return True try: # Get credentials from config self.username = self.config.get_credentials('garmin', 'username') self.password = self.config.get_credentials('garmin', 'password') if not self.username or not self.password: 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() except Exception as e: logger.error(f"Garmin authentication error: {e}") return False def _setup_credentials(self) -> bool: """Setup Garmin credentials interactively""" print("\nšŸ”‘ Garmin Connect Credentials Setup") print("=" * 40) username = input("Enter your Garmin Connect username/email: ").strip() if not username: print("āŒ Username cannot be empty") return False import getpass password = getpass.getpass("Enter your Garmin Connect password: ").strip() if not password: print("āŒ Password cannot be empty") return False # Store credentials in config self.config.set_credentials('garmin', username=username, password=password) self.username = username self.password = password 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""" 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 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)") return 0, len(records) success_count = 0 total_count = len(records) 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 if success: success_count += 1 logger.info(f"Successfully uploaded weight: {record.weight_kg}kg at {record.timestamp}") else: logger.error(f"Failed to upload weight record: {record.weight_kg}kg at {record.timestamp}") # Rate limiting - wait between requests await asyncio.sleep(1) except Exception as e: logger.error(f"Error uploading weight record: {e}") return success_count, total_count - success_count def check_garmin_weights(self, days: int = 30): """Check recent Garmin weights for anomalies""" try: if self.read_only_mode: logger.info("Read-only mode: Cannot check Garmin weights") return recent_weights = self.get_recent_weights(days) if not recent_weights: print("No recent weights found in Garmin") return print(f"\nāš–ļø Recent Garmin Weights (last {days} days):") print("=" * 50) anomalies = [] normal_weights = [] for weight_entry in recent_weights: try: date = weight_entry.get('calendarDate', 'Unknown') weight_raw = weight_entry.get('weight', 0) # Garmin stores weight in grams, convert to kg weight_kg = weight_raw / 1000 if weight_raw else 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)) status = "āŒ ANOMALY" elif weight_kg > 300 or weight_kg < 30: # Suspicious values anomalies.append((date, weight_kg, weight_raw)) status = "āš ļø SUSPICIOUS" else: normal_weights.append((date, weight_kg)) status = "āœ… OK" print(f"šŸ“… {date}: {weight_kg:.1f}kg (raw: {weight_raw}) {status}") except Exception as e: print(f"āŒ Error parsing weight entry: {e}") if anomalies: 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})") if normal_weights: print(f"\nāœ… {len(normal_weights)} normal weight entries found") avg_weight = sum(w[1] for w in normal_weights) / len(normal_weights) print(f"Average weight: {avg_weight:.1f}kg") 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""" def __init__(self, config_file: str = "config.json"): self.config = ConfigManager(config_file) self.db = DatabaseManager(self.config.get('database.path')) self.fitbit = FitbitClient(self.config) self.garmin = GarminClient(self.config) async def setup(self): """Setup and authenticate with services""" logger.info("Setting up Weight Sync Application...") # Authenticate with Fitbit if not await self.fitbit.authenticate(): logger.error("Failed to authenticate with Fitbit") return False # Authenticate with Garmin (unless in read-only mode) if not await self.garmin.authenticate(): if not self.config.get('sync.read_only_mode', False): logger.error("Failed to authenticate with Garmin") return False logger.info("Setup completed successfully") return True async def force_full_sync(self, days: int = 365): """Perform full sync with custom lookback period""" try: logger.info(f"Starting FULL weight data sync (looking back {days} days)...") read_only_mode = self.config.get('sync.read_only_mode', False) if read_only_mode: logger.info("Running in read-only mode - will not upload to Garmin") # Get extended date range for sync end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=days) logger.info(f"Fetching Fitbit data from {start_date.date()} to {end_date.date()}") # Fetch data from Fitbit fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) if not fitbit_records: logger.warning("No weight records found in Fitbit for the specified period") print("āŒ No weight records found in Fitbit for the specified period") return False logger.info(f"Found {len(fitbit_records)} weight records from Fitbit") print(f"šŸ“Š Found {len(fitbit_records)} weight records from Fitbit") # Save new records to database new_records = 0 updated_records = 0 for record in fitbit_records: if self.db.save_weight_record(record): new_records += 1 else: updated_records += 1 logger.info(f"Processed {new_records} new weight records, {updated_records} updated records") print(f"šŸ’¾ Processed {new_records} new records, {updated_records} updated records") # Get unsynced records unsynced_records = self.db.get_unsynced_records() if not unsynced_records: logger.info("No unsynced records found") print("āœ… All records are already synced") return True print(f"šŸ”„ Found {len(unsynced_records)} records to sync to Garmin") # Upload to Garmin (or simulate in read-only mode) success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) # Mark successful uploads as synced synced_count = 0 for record in unsynced_records[:success_count]: if self.db.mark_synced(record.sync_id): synced_count += 1 # Log results mode_prefix = "(Read-only) " if read_only_mode else "" message = f"{mode_prefix}Full sync: {synced_count} records synced, {failed_count} failed" status = "success" if failed_count == 0 else "partial" self.db.log_sync("full_sync", status, message, synced_count) logger.info(f"Full sync completed: {message}") print(f"āœ… Full sync completed: {synced_count} synced, {failed_count} failed") return True except Exception as e: error_msg = f"Full sync failed: {e}" logger.error(error_msg) self.db.log_sync("full_sync", "error", error_msg, 0) print(f"āŒ Full sync failed: {e}") return False async def debug_fitbit_data(self, days: int = 30): """Debug Fitbit data retrieval""" try: logger.info("Setting up Fitbit client for debugging...") if not await self.fitbit.authenticate(): print("āŒ Failed to authenticate with Fitbit") return end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=days) print(f"šŸ” Checking Fitbit data from {start_date.date()} to {end_date.date()}") # Raw API call to see what we get if self.fitbit.client: try: start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") print(f"šŸ“” Making API call: get_bodyweight({start_date_str}, {end_date_str})") weight_data = self.fitbit.client.get_bodyweight( base_date=start_date_str, end_date=end_date_str ) print(f"šŸ“„ Raw Fitbit API response:") print(json.dumps(weight_data, indent=2)) # Also try individual day calls print(f"\nšŸ“… Trying individual day calls for last 7 days:") for i in range(7): check_date = end_date - timedelta(days=i) date_str = check_date.strftime("%Y-%m-%d") try: daily_data = self.fitbit.client.get_bodyweight(base_date=date_str) if daily_data and daily_data.get('body-weight'): print(f" {date_str}: {daily_data['body-weight']}") else: print(f" {date_str}: No data") except Exception as e: print(f" {date_str}: Error - {e}") except Exception as e: print(f"āŒ API call failed: {e}") import traceback print(f"Full traceback: {traceback.format_exc()}") except Exception as e: print(f"āŒ Debug failed: {e}") def reset_sync_status(self): """Reset all records to unsynced status""" try: with sqlite3.connect(self.db.db_path) as conn: result = conn.execute(''' UPDATE weight_records SET synced_to_garmin = FALSE, updated_at = ? ''', (datetime.now().isoformat(),)) affected_rows = result.rowcount logger.info(f"Reset sync status for {affected_rows} records") print(f"šŸ”„ Reset sync status for {affected_rows} records") print(" All records will be synced again on next sync") return True except Exception as e: logger.error(f"Error resetting sync status: {e}") print(f"āŒ Error resetting sync status: {e}") return False async def sync_weight_data(self) -> bool: """Perform weight data synchronization""" try: logger.info("Starting weight data sync...") read_only_mode = self.config.get('sync.read_only_mode', False) if read_only_mode: logger.info("Running in read-only mode - will not upload to Garmin") # Get date range for sync lookback_days = self.config.get('sync.lookback_days', 7) end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=lookback_days) # Fetch data from Fitbit fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) # Save new records to database new_records = 0 for record in fitbit_records: if self.db.save_weight_record(record): new_records += 1 logger.info(f"Processed {new_records} new weight records from Fitbit") # Get unsynced records unsynced_records = self.db.get_unsynced_records() if not unsynced_records: logger.info("No unsynced records found") self.db.log_sync("weight_sync", "success", "No records to sync", 0) return True # Upload to Garmin (or simulate in read-only mode) success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) # Mark successful uploads as synced (even in read-only mode for simulation) synced_count = 0 for record in unsynced_records[:success_count]: if self.db.mark_synced(record.sync_id): synced_count += 1 # Log results mode_prefix = "(Read-only) " if read_only_mode else "" message = f"{mode_prefix}Synced {synced_count} records, {failed_count} failed" status = "success" if failed_count == 0 else "partial" self.db.log_sync("weight_sync", status, message, synced_count) logger.info(f"Sync completed: {message}") return True except Exception as e: error_msg = f"Sync failed: {e}" logger.error(error_msg) self.db.log_sync("weight_sync", "error", error_msg, 0) return False def start_scheduler(self): """Start the sync scheduler""" sync_interval = self.config.get('sync.sync_interval_minutes', 60) logger.info(f"Starting scheduler with {sync_interval} minute interval") # Schedule sync schedule.every(sync_interval).minutes.do( lambda: asyncio.create_task(self.sync_weight_data()) ) # Run initial sync asyncio.create_task(self.sync_weight_data()) # Keep scheduler running while True: schedule.run_pending() time.sleep(60) # Check every minute async def manual_sync(self): """Perform manual sync""" success = await self.sync_weight_data() if success: print("āœ… Manual sync completed successfully") else: print("āŒ Manual sync failed - check logs for details") def show_status(self): """Show application status""" try: read_only_mode = self.config.get('sync.read_only_mode', False) with sqlite3.connect(self.db.db_path) as conn: # Get record counts total_records = conn.execute("SELECT COUNT(*) FROM weight_records").fetchone()[0] synced_records = conn.execute("SELECT COUNT(*) FROM weight_records WHERE synced_to_garmin = TRUE").fetchone()[0] unsynced_records = total_records - synced_records # Get recent sync logs recent_syncs = conn.execute(''' SELECT timestamp, status, message, records_processed FROM sync_log ORDER BY timestamp DESC LIMIT 5 ''').fetchall() print("\nšŸ“Š Weight Sync Status") print("=" * 50) print(f"Mode: {'Read-only (No Garmin uploads)' if read_only_mode else 'Full sync mode'}") print(f"Fitbit Library: {'Available' if FITBIT_LIBRARY else 'Not Available'}") print(f"Garmin Library: {GARMIN_LIBRARY or 'Not Available'}") print(f"Total weight records: {total_records}") print(f"Synced to Garmin: {synced_records}") print(f"Pending sync: {unsynced_records}") print(f"\nšŸ“ Recent Sync History:") if recent_syncs: for sync in recent_syncs: status_emoji = "āœ…" if sync[1] == "success" else "āš ļø" if sync[1] == "partial" else "āŒ" print(f" {status_emoji} {sync[0]} - {sync[1]} - {sync[2]} ({sync[3]} records)") else: print(" No sync history found") # Show recent Garmin weights if available and not in read-only mode if not read_only_mode: try: recent_weights = self.garmin.get_recent_weights(7) if recent_weights: print(f"\nāš–ļø Recent Garmin Weights:") for weight in recent_weights[:5]: # Show last 5 date = weight.get('calendarDate', 'Unknown') weight_kg = weight.get('weight', 0) / 1000 if weight.get('weight') else 'Unknown' print(f" šŸ“… {date}: {weight_kg}kg") except Exception as e: logger.debug(f"Could not fetch recent Garmin weights: {e}") # Show recent database records recent_records = conn.execute(''' SELECT timestamp, weight_kg, source, synced_to_garmin FROM weight_records ORDER BY timestamp DESC LIMIT 5 ''').fetchall() if recent_records: print(f"\nšŸ“ˆ Recent Weight Records:") for record in recent_records: sync_status = "āœ…" if record[3] else "ā³" timestamp = datetime.fromisoformat(record[0]) print(f" {sync_status} {timestamp.strftime('%Y-%m-%d %H:%M')}: {record[1]}kg ({record[2]})") except Exception as e: print(f"āŒ Error getting status: {e}") def toggle_read_only_mode(self): """Toggle read-only mode""" current_mode = self.config.get('sync.read_only_mode', False) new_mode = not current_mode self.config.config['sync']['read_only_mode'] = new_mode self.config.save_config() mode_text = "enabled" if new_mode else "disabled" print(f"āœ… Read-only mode {mode_text}") print(f" {'Will NOT upload to Garmin' if new_mode else 'Will upload to Garmin'}") async def main(): """Main application entry point""" import sys app = WeightSyncApp() if len(sys.argv) > 1: command = sys.argv[1].lower() if command == "setup": success = await app.setup() if success: print("āœ… Setup completed successfully") else: print("āŒ Setup failed") elif command == "sync": await app.setup() await app.manual_sync() elif command == "status": app.show_status() elif command == "reset": app.reset_sync_status() elif command == "fullsync": days = 365 # Default to 1 year if len(sys.argv) > 2: try: days = int(sys.argv[2]) except ValueError: print("āŒ Invalid number of days. Using default 365.") await app.setup() await app.force_full_sync(days) elif command == "check": await app.setup() app.garmin.check_garmin_weights(30) elif command == "testupload": # Test upload with a single fake record await app.setup() test_record = WeightRecord( timestamp=datetime.now(timezone.utc), weight_kg=70.0, # 70kg test weight source="test" ) success, failed = await app.garmin.upload_weight_data([test_record]) print(f"Test upload: {success} successful, {failed} failed") elif command == "debug": days = 30 if len(sys.argv) > 2: try: days = int(sys.argv[2]) except ValueError: print("āŒ Invalid number of days. Using default 30.") await app.debug_fitbit_data(days) elif command == "config": read_only_mode = app.config.get('sync.read_only_mode', False) print(f"šŸ“ Configuration file: {app.config.config_file}") print(f"šŸ“ Database file: {app.config.get('database.path')}") print(f"šŸ“ Log file: weight_sync.log") print(f"šŸ”’ Read-only mode: {'Enabled' if read_only_mode else 'Disabled'}") elif command == "readonly": app.toggle_read_only_mode() elif command == "schedule": await app.setup() try: read_only_mode = app.config.get('sync.read_only_mode', False) print("šŸš€ Starting scheduled sync...") if read_only_mode: print("šŸ“– Running in read-only mode - will NOT upload to Garmin") print("Press Ctrl+C to stop") app.start_scheduler() except KeyboardInterrupt: print("\nšŸ‘‹ Scheduler stopped") else: print("ā“ Unknown command. Available commands:") print(" setup - Initial setup and authentication") print(" sync - Run manual sync") print(" status - Show sync status") print(" config - Show configuration info") print(" readonly - Toggle read-only mode") print(" schedule - Start scheduled sync") else: print("šŸƒ Weight Sync Application") print("Syncs weight data from Fitbit API to Garmin Connect") print("Run with 'python fitbit_sync.py '") print("\nAvailable commands:") print(" setup - Initial setup and authentication") print(" sync - Run manual sync") print(" status - Show sync status") print(" config - Show configuration info") print(" readonly - Toggle read-only mode (prevents Garmin uploads)") print(" schedule - Start scheduled sync") print("\nšŸ’” Tips:") print(" - Use 'readonly' command to toggle between read-only and full sync mode") print(" - Read-only mode will fetch from Fitbit but won't upload to Garmin") print(" - First run 'setup' to configure API credentials") print(" - Check 'status' to see sync history and current mode") # Show current mode read_only_mode = app.config.get('sync.read_only_mode', False) if read_only_mode: print("\nšŸ“– Currently in READ-ONLY mode - will not upload to Garmin") else: print("\nšŸ”„ Currently in FULL SYNC mode - will upload to Garmin") if __name__ == "__main__": asyncio.run(main())