commit 89bf0e69feeb3440b09b7eecf8c6f32774b80e86 Author: sstent Date: Sun Aug 31 06:50:53 2025 -0700 sync diff --git a/fitbitsync.py b/fitbitsync.py new file mode 100644 index 0000000..c51e816 --- /dev/null +++ b/fitbitsync.py @@ -0,0 +1,1418 @@ +# 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()) \ No newline at end of file diff --git a/fitbitsync_debug.py b/fitbitsync_debug.py new file mode 100644 index 0000000..006f0bb --- /dev/null +++ b/fitbitsync_debug.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Debug script to test Fitbit library installation and functionality +""" + +import sys + +def test_fitbit_import(): + """Test importing the fitbit library""" + print("Testing Fitbit library import...") + + try: + import fitbit + print("āœ… Successfully imported 'fitbit' module") + print(f" Version: {getattr(fitbit, '__version__', 'Unknown')}") + print(f" Location: {fitbit.__file__}") + except ImportError as e: + print(f"āŒ Failed to import 'fitbit': {e}") + return False + + try: + from fitbit.api import FitbitOauth2Client + print("āœ… Successfully imported 'FitbitOauth2Client'") + except ImportError as e: + print(f"āŒ Failed to import 'FitbitOauth2Client': {e}") + return False + + try: + # Test creating a basic client + client = fitbit.Fitbit("test_id", "test_secret") + print("āœ… Successfully created basic Fitbit client") + except Exception as e: + print(f"āŒ Failed to create basic Fitbit client: {e}") + return False + + return True + +def test_oauth_client(): + """Test creating OAuth client""" + print("\nTesting OAuth client creation...") + + try: + from fitbit.api import FitbitOauth2Client + oauth_client = FitbitOauth2Client( + "test_id", + "test_secret", + redirect_uri="http://localhost:8080/callback" + ) + print("āœ… Successfully created OAuth client") + + # Test getting authorization URL + try: + auth_url, state = oauth_client.authorize_token_url() + print("āœ… Successfully generated authorization URL") + print(f" Sample URL: {auth_url[:100]}...") + except Exception as e: + print(f"āŒ Failed to generate authorization URL: {e}") + return False + + except Exception as e: + print(f"āŒ Failed to create OAuth client: {e}") + return False + + return True + +def main(): + print("šŸ” Fitbit Library Debug Script") + print("=" * 50) + + print(f"Python version: {sys.version}") + print(f"Python executable: {sys.executable}") + print() + + # Test basic import + if not test_fitbit_import(): + print("\nšŸ’” Installation suggestion:") + print(" pip install fitbit") + print(" or") + print(" pip install python-fitbit") + return + + # Test OAuth functionality + if not test_oauth_client(): + print("\nāŒ OAuth client test failed") + return + + print("\nāœ… All tests passed! Fitbit library should work correctly.") + print("\nšŸ”§ If you're still having issues, try:") + print(" 1. Reinstalling: pip uninstall fitbit && pip install fitbit") + print(" 2. Using a virtual environment") + print(" 3. Checking for conflicting packages") + +if __name__ == "__main__": + main() \ No newline at end of file