From 3a4563a34d3ed26ecab697f4134242b9f6f48698 Mon Sep 17 00:00:00 2001 From: sstent Date: Sat, 4 Oct 2025 16:08:55 -0700 Subject: [PATCH] sync --- Dockerfile | 23 + Makefile | 62 ++ README.md | 81 ++ __pycache__/fitbit.cpython-313.pyc | Bin 0 -> 8365 bytes fitbitsync.py | 138 ++- fitbitsync_debug.py | 1415 ++++++++++++++++++++++++++-- fitsync_garthtest.py | 109 +++ requirements.txt | 4 + test.md | 9 + 9 files changed, 1714 insertions(+), 127 deletions(-) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 __pycache__/fitbit.cpython-313.pyc create mode 100644 fitsync_garthtest.py create mode 100644 requirements.txt create mode 100644 test.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6faf462 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Use an official Python runtime as a parent image +FROM python:3.13-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the dependencies file to the working directory +COPY requirements.txt . + +# Install any needed packages specified in requirements.txt +RUN pip install --upgrade pip; pip install --no-cache-dir --upgrade -r requirements.txt + +# Copy the rest of the application's code to the working directory +COPY . . + +# Set up a volume for persistent data +VOLUME /app/data + +# The command to run the application +ENTRYPOINT ["python", "fitbitsync.py"] + +# Default command to run when container starts +CMD ["schedule"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9e62a91 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +# Makefile for Fitbit to Garmin Weight Sync Docker Application + +# Variables +IMAGE = fitbit-garmin-sync +DATA_DIR = data +VOLUME = -v "$(PWD)/$(DATA_DIR)":/app/data +CONTAINER_NAME = fitbit-sync + +# Default target +.PHONY: help +help: + @echo "Available targets:" + @echo " build - Build the Docker image" + @echo " data - Create the data directory for persistence" + @echo " run - Run the application in scheduled sync mode (detached)" + @echo " setup - Run interactive setup for credentials" + @echo " sync - Run manual sync" + @echo " status - Check application status" + @echo " stop - Stop the running container" + @echo " clean - Stop and remove container, remove image" + @echo " help - Show this help message" + +# Build the Docker image +.PHONY: build +build: + docker build -t $(IMAGE) . + +# Create data directory +.PHONY: data +data: + mkdir -p $(DATA_DIR) + +# Run the scheduled sync (detached) +.PHONY: run +run: build data + docker run -d --name $(CONTAINER_NAME) $(VOLUME) $(IMAGE) + +# Interactive setup +.PHONY: setup +setup: build data + docker run -it --rm $(VOLUME) $(IMAGE) setup + +# Manual sync +.PHONY: sync +sync: build data + docker run -it --rm $(VOLUME) $(IMAGE) sync + +# Check status +.PHONY: status +status: build data + docker run -it --rm $(VOLUME) $(IMAGE) status + +# Stop the running container +.PHONY: stop +stop: + docker stop $(CONTAINER_NAME) || true + docker rm $(CONTAINER_NAME) || true + +# Clean up: stop container, remove image +.PHONY: clean +clean: stop + docker rmi $(IMAGE) || true \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0def727 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Fitbit to Garmin Weight Sync - Dockerized + +This application syncs weight data from the Fitbit API to Garmin Connect. This README provides instructions on how to run the application using Docker. + +## Prerequisites + +- Docker must be installed on your system. + +## Building the Docker Image + +To build the Docker image for this application, run the following command from the root directory of the project: + +```bash +docker build -t fitbit-garmin-sync . +``` + +## Running the Application + +The application requires persistent storage for configuration, database, logs, and session data. You should create a local directory to store this data and mount it as a volume when running the container. + +1. **Create a data directory on your host machine:** + + ```bash + mkdir fitbit_garmin_data + ``` + +2. **Run the Docker container with a mounted volume:** + + The application can be run in several modes. The default command is `schedule` to run the sync on a schedule. + + ```bash + docker run -d --name fitbit-sync -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync + ``` + + This will start the container in detached mode (`-d`) and run the scheduled sync. + +### Interactive Setup + +The first time you run the application, you will need to perform an interactive setup to provide your Fitbit and Garmin credentials. + +1. **Run the container with the `setup` command:** + + ```bash + docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync setup + ``` + + - `-it` allows you to interact with the container's terminal. + - `--rm` will remove the container after it exits. + +2. **Follow the on-screen prompts** to enter your Fitbit and Garmin credentials. The application will guide you through the OAuth process for Fitbit, which requires you to copy and paste a URL into your browser. + + After the setup is complete, the necessary configuration and session files will be saved in your `fitbit_garmin_data` directory. + +### Other Commands + +You can run other commands by specifying them when you run the container. For example, to run a manual sync: + +```bash +docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync sync +``` + +To check the status: + +```bash +docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync status +``` + +## Data Persistence + +The following files will be stored in the mounted data volume (`fitbit_garmin_data`): + +- `config.json`: Application configuration, including API keys. +- `weight_sync.db`: SQLite database for storing sync state. +- `weight_sync.log`: Log file. +- `garmin_session.json`: Garmin session data. + +By using a volume, this data will persist even if the container is stopped or removed. + +## Managing Credentials + +Your Fitbit and Garmin credentials are an essential part of the `config.json` file, which is stored in the data volume. Be sure to treat this data as sensitive. It is recommended to restrict permissions on the `fitbit_garmin_data` directory. \ No newline at end of file diff --git a/__pycache__/fitbit.cpython-313.pyc b/__pycache__/fitbit.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b42cb9251c3363f243769aebcaa03e82796ad5c9 GIT binary patch literal 8365 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 literal 0 HcmV?d00001 diff --git a/fitbitsync.py b/fitbitsync.py index 13008d0..100fb97 100644 --- a/fitbitsync.py +++ b/fitbitsync.py @@ -34,7 +34,7 @@ logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ - logging.FileHandler('weight_sync.log'), + logging.FileHandler('data/weight_sync.log'), logging.StreamHandler() ] ) @@ -57,8 +57,9 @@ class WeightRecord: class ConfigManager: """Manages application configuration and credentials""" - def __init__(self, config_file: str = "config.json"): + def __init__(self, config_file: str = "data/config.json"): self.config_file = Path(config_file) + self.config_file.parent.mkdir(parents=True, exist_ok=True) self.config = self._load_config() def _load_config(self) -> Dict: @@ -90,14 +91,14 @@ class ConfigManager: "client_secret": "", "access_token": "", "refresh_token": "", - "token_file": "fitbit_token.json", + "token_file": "data/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" + "session_data_file": "data/garmin_session.json" }, "sync": { "sync_interval_minutes": 60, @@ -106,7 +107,7 @@ class ConfigManager: "read_only_mode": False # Set to True to prevent uploads to Garmin }, "database": { - "path": "weight_sync.db" + "path": "data/weight_sync.db" } } # Don't automatically save here, let the caller decide @@ -143,7 +144,7 @@ class ConfigManager: "client_secret": "", "access_token": "", "refresh_token": "", - "token_file": "fitbit_token.json", + "token_file": "data/fitbit_token.json", "redirect_uri": "http://localhost:8080/fitbit-callback" } @@ -542,7 +543,9 @@ class GarminClient: 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') + # Resolve session file path relative to config file location + session_file_rel = config.get('garmin.session_data_file', 'garmin_session.json') + self.session_file = config.config_file.parent / session_file_rel self.garmin_client = None self.read_only_mode = config.get('sync.read_only_mode', False) @@ -551,6 +554,29 @@ class GarminClient: import garminconnect self.garminconnect = garminconnect logger.info("Using garminconnect library") + + # Monkey patch the login method to handle garth compatibility issue + original_login = self.garminconnect.Garmin.login + + def patched_login(self): + """Patched login method that handles garth returning None""" + try: + result = original_login(self) + return result + except TypeError as e: + if "cannot unpack non-iterable NoneType object" in str(e): + # Check if we have valid tokens despite the None return + if (self.garth.oauth1_token and self.garth.oauth2_token): + logger.info("Login successful (handled garth None return)") + return True + else: + raise + else: + raise + + # Apply the patch + self.garminconnect.Garmin.login = patched_login + except ImportError: logger.error("garminconnect library not installed. Install with: pip install garminconnect") raise ImportError("garminconnect library is required but not installed") @@ -560,57 +586,73 @@ class GarminClient: 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() - - # 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 + if not self._setup_credentials(): + return False + + logger.info("Initializing Garmin client...") 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 + + # Use garth to load the session if it exists + if os.path.exists(self.session_file): + try: + logger.info(f"Attempting to load session from {self.session_file}") + self.garmin_client.garth.load(self.session_file) + logger.info("Loaded existing session from file.") + # Log garth state after loading + logger.info(f"Garth tokens after load: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + except Exception as e: + logger.warning(f"Could not load session file: {e}. Performing fresh login.") + + # Login (will use loaded session or perform a fresh auth) + logger.info("Calling garmin_client.login()...") + try: + # Handle garth API compatibility issue - newer versions return None + # when using existing sessions, but garminconnect expects a tuple + login_result = self.garmin_client.login() + + # Check if login returned None (new garth behavior with existing sessions) + if login_result is None: + # Verify that we actually have valid tokens after login + if (self.garmin_client.garth.oauth1_token and + self.garmin_client.garth.oauth2_token): + logger.info("Login successful (garth returned None but tokens are valid)") + else: + logger.error("Login failed - garth returned None and no valid tokens") + raise Exception("Garmin login failed: No valid tokens after authentication") + else: + logger.info("Login successful") + + except Exception as e: + logger.error(f"Login failed with exception: {e}") + # Log garth state before re-raising + logger.info(f"Garth tokens before failure: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + raise + + # Save the session using garth's dump method + self.garmin_client.garth.dump(self.session_file) + profile = self.garmin_client.get_full_name() - logger.info(f"Successfully authenticated for user: {profile}") - + logger.info(f"Successfully authenticated and saved session for user: {profile}") return True - + except Exception as e: logger.error(f"Garmin authentication error: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") return False - + def _setup_credentials(self) -> bool: """Setup Garmin credentials interactively""" print("\nšŸ”‘ Garmin Connect Credentials Setup") @@ -750,7 +792,8 @@ class GarminClient: try: logger.info("Attempting to re-authenticate...") self.garmin_client.login() - self.garmin_client.save_session(self.session_file) + # Correctly save the new session data using garth + self.garmin_client.garth.dump(self.session_file) # Retry the upload result = self.garmin_client.add_body_composition( @@ -878,9 +921,14 @@ class GarminClient: class WeightSyncApp: """Main application class""" - def __init__(self, config_file: str = "config.json"): + def __init__(self, config_file: str = "data/config.json"): self.config = ConfigManager(config_file) - self.db = DatabaseManager(self.config.get('database.path')) + + # Construct full paths for data files + data_dir = self.config.config_file.parent + db_path = data_dir / self.config.get('database.path', 'weight_sync.db') + + self.db = DatabaseManager(db_path) self.fitbit = FitbitClient(self.config) self.garmin = GarminClient(self.config) diff --git a/fitbitsync_debug.py b/fitbitsync_debug.py index 006f0bb..90298e1 100644 --- a/fitbitsync_debug.py +++ b/fitbitsync_debug.py @@ -1,94 +1,1345 @@ -#!/usr/bin/env python3 -""" -Debug script to test Fitbit library installation and functionality -""" -import sys +# Fitbit to Garmin Weight Sync Application - Debug Version +# Adds detailed logging for Garmin authentication issues -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 +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 -def test_oauth_client(): - """Test creating OAuth client""" - print("\nTesting OAuth client creation...") +try: + import fitbit + FITBIT_LIBRARY = True +except ImportError: + FITBIT_LIBRARY = False + +try: + import garminconnect + GARMIN_LIBRARY = "garminconnect" +except ImportError: + GARMIN_LIBRARY = None + +import schedule + +# Configure logging with DEBUG level +logging.basicConfig( + level=logging.DEBUG, # Changed to DEBUG for detailed logging + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('weight_sync_debug.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 - try: - from fitbit.api import FitbitOauth2Client - oauth_client = FitbitOauth2Client( - "test_id", - "test_secret", - redirect_uri="http://localhost:8080/callback" - ) - print("āœ… Successfully created OAuth client") + 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() - # 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 _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 main(): - print("šŸ” Fitbit Library Debug Script") - print("=" * 50) + 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 - print(f"Python version: {sys.version}") - print(f"Python executable: {sys.executable}") - print() + 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) - # Test basic import - if not test_fitbit_import(): - print("\nšŸ’” Installation suggestion:") - print(" pip install fitbit") - print(" or") - print(" pip install python-fitbit") - return + 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 - # Test OAuth functionality - if not test_oauth_client(): - print("\nāŒ OAuth client test failed") - return + 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() - 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") + 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 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) + + # 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""" + 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.") + if not self._setup_credentials(): + return False + + logger.info("Initializing Garmin client...") + self.garmin_client = self.garminconnect.Garmin( + self.username, + self.password, + is_cn=self.is_china + ) + + # Use garth to load the session if it exists + if os.path.exists(self.session_file): + try: + logger.info(f"Attempting to load session from {self.session_file}") + self.garmin_client.garth.load(self.session_file) + logger.info("Loaded existing session from file.") + # Log garth state after loading + logger.info(f"Garth tokens after load: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + except Exception as e: + logger.warning(f"Could not load session file: {e}. Performing fresh login.") + + # Login (will use loaded session or perform a fresh auth) + logger.info("Calling garmin_client.login()...") + try: + # DEBUG: Add detailed logging around the login call + logger.debug(f"Before login - garth state: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + logger.debug(f"Session file path: {self.session_file}") + logger.debug(f"Session file exists: {os.path.exists(self.session_file)}") + + # Call login and capture the return value + login_result = self.garmin_client.login() + logger.debug(f"Login result: {login_result}") + logger.debug(f"After login - garth state: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + + logger.info("Login successful") + except Exception as e: + logger.error(f"Login failed with exception: {e}") + # Log garth state before re-raising + logger.info(f"Garth tokens before failure: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}") + # Add more detailed error information + import traceback + logger.error(f"Full login error traceback: {traceback.format_exc()}") + raise + + # Save the session using garth's dump method + self.garmin_client.garth.dump(self.session_file) + + profile = self.garmin_client.get_full_name() + logger.info(f"Successfully authenticated and saved session for user: {profile}") + return True + + except Exception as e: + logger.error(f"Garmin authentication error: {e}") + import traceback + logger.error(f"Full traceback: {traceback.format_exc()}") + 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 upload_weight_data(self, records: List[WeightRecord]) -> Tuple[int, int]: + """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") + for record in records: + logger.info(f"Read-only mode: Would upload {record.weight_kg}kg at {record.timestamp}") + return len(records), 0 + + if not self.garmin_client: + logger.error("Garmin client not authenticated") + return 0, len(records) + + success_count = 0 + total_count = len(records) + + for record in records: + try: + success = await self._upload_weight_garminconnect(record) + + 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(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() + # Correctly save the new session data using garth + self.garmin_client.garth.dump(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: + 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: + # 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') + + # 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 > 300 or weight_kg < 30: # Clearly wrong values + anomalies.append((date, weight_kg)) + status = "āŒ ANOMALY" + 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 {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 in anomalies: + print(f" - {date}: {weight_kg:.1f}kg") + + 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}") + +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__": - main() \ No newline at end of file + asyncio.run(main()) diff --git a/fitsync_garthtest.py b/fitsync_garthtest.py new file mode 100644 index 0000000..d27d4ba --- /dev/null +++ b/fitsync_garthtest.py @@ -0,0 +1,109 @@ +import json +import sys +import time +import garth +from datetime import datetime + +def main(): + try: + # Load configuration + with open('config.json') as f: + config = json.load(f) + + garmin_config = config.get('garmin', {}) + username = garmin_config.get('username') + password = garmin_config.get('password') + is_china = garmin_config.get('is_china', False) + session_file = garmin_config.get('session_data_file', 'garmin_session.json') + + if not username or not password: + print("āŒ Missing Garmin credentials in config.json") + return 1 + + # Set domain based on region + domain = "garmin.cn" if is_china else "garmin.com" + garth.configure(domain=domain) + + try: + # Authentication attempt + start_time = time.time() + garth.login(username, password) + end_time = time.time() + + print("āœ… Authentication successful!") + print(f"ā±ļø Authentication time: {end_time - start_time:.2f} seconds") + garth.save(session_file) + return 0 + + except garth.exc.GarthHTTPError as e: + end_time = time.time() # Capture time when error occurred + # Extract information from the exception message + error_msg = str(e) + print(f"\nāš ļø Garth HTTP Error: {error_msg}") + + # Check if it's a 429 error by parsing the message + if "429" in error_msg: + print("=" * 50) + print("Rate Limit Exceeded (429)") + print(f"Response Time: {end_time - start_time:.2f} seconds") + + # Try to extract URL from error message + url_start = error_msg.find("url: ") + if url_start != -1: + url = error_msg[url_start + 5:] + print(f"URL: {url}") + + # Try to access response headers if available + if hasattr(e, 'response') and e.response is not None: + headers = e.response.headers + retry_after = headers.get('Retry-After') + reset_timestamp = headers.get('X-RateLimit-Reset') + remaining = headers.get('X-RateLimit-Remaining') + limit = headers.get('X-RateLimit-Limit') + + print("\nšŸ“Š Rate Limit Headers Found:") + if retry_after: + print(f"ā³ Retry-After: {retry_after} seconds") + wait_time = int(retry_after) + reset_time = datetime.utcfromtimestamp(time.time() + wait_time) + print(f"ā° Estimated reset time: {reset_time} UTC") + if reset_timestamp: + try: + reset_time = datetime.utcfromtimestamp(float(reset_timestamp)) + print(f"ā° Rate limit resets at: {reset_time} UTC") + except ValueError: + print(f"āš ļø Invalid reset timestamp: {reset_timestamp}") + if remaining: + print(f"šŸ”„ Requests remaining: {remaining}") + if limit: + print(f"šŸ“ˆ Rate limit: {limit} requests per window") + if not any([retry_after, reset_timestamp, remaining, limit]): + print("ā„¹ļø No rate limit headers found in response") + else: + print("\nāš ļø Response headers not accessible directly from GarthHTTPError") + print("Common rate limit headers to look for:") + print(" - Retry-After: Seconds to wait before retrying") + print(" - X-RateLimit-Limit: Maximum requests per time window") + print(" - X-RateLimit-Remaining: Remaining requests in current window") + print(" - X-RateLimit-Reset: Time when rate limit resets") + print(f"\nRecommend waiting at least 60 seconds before retrying.") + + return 429 + + # Handle other HTTP errors + print(f"āŒ HTTP Error detected: {error_msg}") + return 1 + + except Exception as e: + print(f"āŒ Authentication failed: {str(e)}") + return 1 + + except FileNotFoundError: + print("āŒ config.json file not found") + return 1 + except json.JSONDecodeError: + print("āŒ Error parsing config.json") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b4f0d0b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fitbit==0.3.1 +garminconnect==0.2.30 +garth==0.5.17 +schedule==1.2.2 diff --git a/test.md b/test.md new file mode 100644 index 0000000..b25fe73 --- /dev/null +++ b/test.md @@ -0,0 +1,9 @@ +# List of Files in Project + +- [ ] config.json +- [ ] fitbitsync_debug.py +- [ ] fitbitsync.py +- [ ] fitsync_garthtest.py +- [ ] weight_sync.db +- [ ] weight_sync.log +- [ ] garmin_session.json/ \ No newline at end of file