# Fitbit to Garmin Weight Sync Application # Syncs weight data from Fitbit API to Garmin Connect # All state and configuration stored in Consul K/V store import base64 import sys import asyncio import json import logging from datetime import datetime, timedelta, timezone from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, asdict import hashlib import time import webbrowser from urllib.parse import urlparse, parse_qs try: import fitbit FITBIT_LIBRARY = True except ImportError: FITBIT_LIBRARY = False try: import garth GARTH_LIBRARY = True except ImportError: GARTH_LIBRARY = False try: import garminconnect GARMINCONNECT_LIBRARY = True except ImportError: GARMINCONNECT_LIBRARY = False try: import consul CONSUL_LIBRARY = True except ImportError: CONSUL_LIBRARY = False import schedule # Configure logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[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: unique_string = f"{self.timestamp.isoformat()}_{self.weight_kg}" self.sync_id = hashlib.md5(unique_string.encode()).hexdigest() class ConsulManager: """Manages all configuration and state in Consul K/V store""" def __init__(self, host: str = "consul.service.dc1.consul", port: int = 8500, prefix: str = "fitbit-garmin-sync"): if not CONSUL_LIBRARY: raise ImportError("python-consul library not installed. Please install it with: pip install python-consul") self.client = consul.Consul(host=host, port=port) self.prefix = prefix.strip('/') self.config_key = f"{self.prefix}/config" self.records_prefix = f"{self.prefix}/records/" self.logs_prefix = f"{self.prefix}/logs/" logger.info(f"Using Consul at {host}:{port} with prefix '{self.prefix}'") # Initialize default config if it doesn't exist self._ensure_config_exists() def _ensure_config_exists(self): """Ensure configuration exists in Consul with defaults""" index, data = self.client.kv.get(self.config_key) if not data: logger.info("No configuration found in Consul, creating defaults...") default_config = { "fitbit": { "client_id": "", "client_secret": "", "access_token": "", "refresh_token": "", "redirect_uri": "http://localhost:8080/fitbit-callback" }, "garmin": { "username": "", "password": "", "is_china": False, "garth_oauth1_token": "", "garth_oauth2_token": "" }, "sync": { "sync_interval_minutes": 60, "lookback_days": 7, "max_retries": 3, "read_only_mode": False } } self._save_config(default_config) def _save_config(self, config: Dict): """Save configuration to Consul""" self.client.kv.put(self.config_key, json.dumps(config)) logger.info("Configuration saved to Consul") def get_config(self) -> Dict: """Get configuration from Consul""" index, data = self.client.kv.get(self.config_key) if not data or not data.get('Value'): logger.error("No configuration found in Consul") return {} try: decoded_json_str = data['Value'].decode('utf-8') except UnicodeDecodeError: encoded_value = data['Value'] padding_needed = len(encoded_value) % 4 if padding_needed != 0: encoded_value += b'=' * (4 - padding_needed) decoded_json_str = base64.b64decode(encoded_value).decode('utf-8') return json.loads(decoded_json_str) def update_config(self, section: str, updates: Dict): """Update a section of the configuration""" config = self.get_config() if section not in config: config[section] = {} config[section].update(updates) self._save_config(config) def get_config_value(self, path: str, default=None): """Get a configuration value using dot notation""" config = self.get_config() keys = path.split('.') value = config for key in keys: if isinstance(value, dict): value = value.get(key, {}) else: return default return value if value != {} else default def save_weight_record(self, record: WeightRecord) -> bool: """Save weight record to Consul if it doesn't exist""" key = f"{self.records_prefix}{record.sync_id}" try: index, data = self.client.kv.get(key) if data is not None: return False record_data = asdict(record) record_data['timestamp'] = record.timestamp.isoformat() record_data['synced_to_garmin'] = False self.client.kv.put(key, json.dumps(record_data)) return True except Exception as e: logger.error(f"Error saving weight record to Consul: {e}") return False def get_unsynced_records(self) -> List[WeightRecord]: """Get records from Consul that haven't been synced to Garmin""" records = [] try: index, keys = self.client.kv.get(self.records_prefix, keys=True) if not keys: return [] logger.info(f"Scanning {len(keys)} records from Consul to find unsynced items") for key in keys: index, data = self.client.kv.get(key) if data and data.get('Value'): try: record_data = json.loads(data['Value']) if not record_data.get('synced_to_garmin'): record = WeightRecord( sync_id=record_data['sync_id'], timestamp=datetime.fromisoformat(record_data['timestamp']), weight_kg=record_data['weight_kg'], source=record_data['source'] ) records.append(record) except (json.JSONDecodeError, KeyError) as e: logger.warning(f"Could not parse record from key {key}: {e}") except Exception as e: logger.error(f"Error getting unsynced records: {e}") records.sort(key=lambda r: r.timestamp, reverse=True) return records def mark_synced(self, sync_id: str) -> bool: """Mark a record as synced to Garmin""" key = f"{self.records_prefix}{sync_id}" try: for _ in range(5): index, data = self.client.kv.get(key) if data is None: logger.warning(f"Cannot mark sync_id {sync_id} as synced: record not found") return False record_data = json.loads(data['Value']) record_data['synced_to_garmin'] = True success = self.client.kv.put(key, json.dumps(record_data), cas=data['ModifyIndex']) if success: return True time.sleep(0.1) logger.error(f"Failed to mark record {sync_id} as synced after retries") return False 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 to Consul""" log_entry = { "sync_type": sync_type, "status": status, "message": message, "records_processed": records_processed, "timestamp": datetime.now(timezone.utc).isoformat() } key = f"{self.logs_prefix}{log_entry['timestamp']}" try: self.client.kv.put(key, json.dumps(log_entry)) except Exception as e: logger.error(f"Error logging sync: {e}") def reset_sync_status(self) -> int: """Reset all records to unsynced status""" affected_rows = 0 try: index, keys = self.client.kv.get(self.records_prefix, keys=True) if not keys: return 0 logger.info(f"Resetting sync status for {len(keys)} records...") for key in keys: try: for _ in range(3): index, data = self.client.kv.get(key) if data and data.get('Value'): record_data = json.loads(data['Value']) if record_data.get('synced_to_garmin'): record_data['synced_to_garmin'] = False success = self.client.kv.put(key, json.dumps(record_data), cas=data['ModifyIndex']) if success: affected_rows += 1 break else: break except Exception as e: logger.warning(f"Failed to reset sync status for key {key}: {e}") return affected_rows except Exception as e: logger.error(f"Error resetting sync status: {e}") return 0 def get_status_info(self) -> Dict: """Get status info from Consul""" status_info = { "total_records": 0, "synced_records": 0, "unsynced_records": 0, "recent_syncs": [], "recent_records": [] } try: index, keys = self.client.kv.get(self.records_prefix, keys=True) if keys: status_info['total_records'] = len(keys) synced_count = 0 all_records = [] for key in keys: index, data = self.client.kv.get(key) if data and data.get('Value'): record_data = json.loads(data['Value']) all_records.append(record_data) if record_data.get('synced_to_garmin'): synced_count += 1 status_info['synced_records'] = synced_count status_info['unsynced_records'] = status_info['total_records'] - synced_count all_records.sort(key=lambda r: r.get('timestamp', ''), reverse=True) for record in all_records[:5]: status_info['recent_records'].append(( record['timestamp'], record['weight_kg'], record['source'], record['synced_to_garmin'] )) index, log_keys = self.client.kv.get(self.logs_prefix, keys=True) if log_keys: log_keys.sort(reverse=True) for key in log_keys[:5]: index, data = self.client.kv.get(key) if data and data.get('Value'): log_data = json.loads(data['Value']) status_info['recent_syncs'].append(( log_data['timestamp'], log_data['status'], log_data['message'], log_data['records_processed'] )) except Exception as e: logger.error(f"Error getting status info: {e}") return status_info class FitbitClient: """Client for Fitbit API using python-fitbit""" def __init__(self, consul: ConsulManager): self.consul = consul self.client = None if not FITBIT_LIBRARY: raise ImportError("python-fitbit library not installed. Install with: pip install fitbit") async def authenticate(self) -> bool: """Authenticate with Fitbit API""" try: config = self.consul.get_config() fitbit_config = config.get('fitbit', {}) client_id = fitbit_config.get('client_id') client_secret = fitbit_config.get('client_secret') if not client_id or not client_secret: logger.info("No Fitbit credentials found in Consul") if not self._setup_credentials(): return False config = self.consul.get_config() fitbit_config = config.get('fitbit', {}) client_id = fitbit_config.get('client_id') client_secret = fitbit_config.get('client_secret') access_token = fitbit_config.get('access_token') refresh_token = fitbit_config.get('refresh_token') if access_token and refresh_token: try: self.client = fitbit.Fitbit( client_id, client_secret, access_token=access_token, refresh_token=refresh_token, refresh_cb=self._token_refresh_callback ) profile = self.client.user_profile_get() logger.info(f"Authenticated with existing tokens for: {profile['user']['displayName']}") return True except Exception as e: logger.warning(f"Existing tokens invalid: {e}") self.consul.update_config('fitbit', {'access_token': '', 'refresh_token': ''}) return await self._oauth_flow(client_id, client_secret) except Exception as e: logger.error(f"Fitbit authentication error: {e}") return False def _setup_credentials(self) -> bool: """Setup Fitbit credentials interactively""" if not sys.stdout.isatty(): logger.error("Cannot prompt for credentials in non-interactive environment") return False 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 self.consul.update_config('fitbit', { 'client_id': client_id, 'client_secret': client_secret }) print("āœ… Credentials saved to Consul") return True async def _oauth_flow(self, client_id: str, client_secret: str) -> bool: """Perform OAuth 2.0 authorization flow""" if not sys.stdout.isatty(): logger.error("Cannot perform OAuth flow in non-interactive environment") return False try: config = self.consul.get_config() redirect_uri = config.get('fitbit', {}).get('redirect_uri') from fitbit.api import FitbitOauth2Client auth_client = FitbitOauth2Client(client_id, client_secret, redirect_uri=redirect_uri) 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, copy the FULL URL from your browser's address bar.") print() try: webbrowser.open(auth_url) except Exception as e: logger.warning(f"Could not open browser: {e}") callback_url = input("After authorization, paste the full callback URL here: ").strip() if not callback_url: print("āŒ Callback URL cannot be empty") return False 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") return False auth_code = query_params['code'][0] token = auth_client.fetch_access_token(auth_code) self.consul.update_config('fitbit', { 'client_id': client_id, 'client_secret': client_secret, 'access_token': token['access_token'], 'refresh_token': token['refresh_token'] }) self.client = fitbit.Fitbit( client_id, client_secret, access_token=token['access_token'], refresh_token=token['refresh_token'], refresh_cb=self._token_refresh_callback ) 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}") 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") config = self.consul.get_config() fitbit_config = config.get('fitbit', {}) self.consul.update_config('fitbit', { 'client_id': fitbit_config.get('client_id'), 'client_secret': fitbit_config.get('client_secret'), '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 {start_date.date()} to {end_date.date()}") records = [] try: start_date_str = start_date.strftime("%Y-%m-%d") end_date_str = end_date.strftime("%Y-%m-%d") weight_data = self.client.get_bodyweight( base_date=start_date_str, end_date=end_date_str ) weight_entries = None if weight_data: if 'weight' in weight_data: weight_entries = weight_data['weight'] elif 'body-weight' in weight_data: weight_entries = weight_data['body-weight'] if weight_entries: logger.info(f"Processing {len(weight_entries)} weight entries") for weight_entry in weight_entries: try: date_str = weight_entry['date'] time_str = weight_entry.get('time', '00:00:00') datetime_str = f"{date_str} {time_str}" timestamp = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S") timestamp = timestamp.replace(tzinfo=timezone.utc) weight_lbs = float(weight_entry['weight']) weight_kg = weight_lbs * 0.453592 record = WeightRecord( timestamp=timestamp, weight_kg=weight_kg, source="fitbit" ) records.append(record) logger.info(f"Found weight: {weight_lbs}lbs ({weight_kg:.1f}kg) at {timestamp}") except Exception as e: logger.warning(f"Failed to parse weight entry: {e}") continue logger.info(f"Retrieved {len(records)} weight records from Fitbit") except Exception as e: logger.error(f"Error fetching Fitbit weight data: {e}") return records class GarminClient: """Client for Garmin Connect using garminconnect library""" def __init__(self, consul: ConsulManager): self.consul = consul self.garmin_client = None try: import garminconnect self.garminconnect = garminconnect except ImportError: raise ImportError("garminconnect library not installed. Install with: pip install garminconnect") async def authenticate(self) -> bool: """Authenticate with Garmin Connect""" config = self.consul.get_config() if config.get('sync', {}).get('read_only_mode', False): logger.info("Running in read-only mode - skipping Garmin authentication") return True try: garmin_config = config.get('garmin', {}) username = garmin_config.get('username') password = garmin_config.get('password') is_china = garmin_config.get('is_china', False) if not username or not password: logger.info("No Garmin credentials found in Consul") if not self._setup_credentials(): return False config = self.consul.get_config() garmin_config = config.get('garmin', {}) username = garmin_config.get('username') password = garmin_config.get('password') if is_china: garth.configure(domain="garmin.cn") tokens_loaded = self._load_garth_tokens() if not tokens_loaded: logger.info("No existing Garmin tokens, performing fresh login...") garth.login(username, password) self._save_garth_tokens() self.garmin_client = self.garminconnect.Garmin(username, password) self.garmin_client.garth = garth.client profile = self.garmin_client.get_full_name() logger.info(f"Successfully authenticated with Garmin for: {profile}") return True except Exception as e: logger.error(f"Garmin authentication error: {e}") return False def _setup_credentials(self) -> bool: """Setup Garmin credentials interactively""" if not sys.stdout.isatty(): logger.error("Cannot prompt for credentials in non-interactive environment") return False 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 self.consul.update_config('garmin', { 'username': username, 'password': password }) print("āœ… Credentials saved to Consul") return True def _save_garth_tokens(self): """Save garth tokens to Consul""" try: oauth1_token = garth.client.oauth1_token oauth2_token = garth.client.oauth2_token updates = {} if oauth1_token: token_dict = oauth1_token.__dict__ for k, v in token_dict.items(): if isinstance(v, datetime): token_dict[k] = v.isoformat() updates['garth_oauth1_token'] = json.dumps(token_dict) logger.info("Saved OAuth1 token to Consul") if oauth2_token: token_dict = oauth2_token.__dict__ for k, v in token_dict.items(): if isinstance(v, datetime): token_dict[k] = v.isoformat() updates['garth_oauth2_token'] = json.dumps(token_dict) logger.info("Saved OAuth2 token to Consul") if updates: self.consul.update_config('garmin', updates) except Exception as e: logger.warning(f"Failed to save garth tokens: {e}") def _load_garth_tokens(self) -> bool: """Load garth tokens from Consul""" try: config = self.consul.get_config() garmin_config = config.get('garmin', {}) oauth1_json = garmin_config.get('garth_oauth1_token') oauth2_json = garmin_config.get('garth_oauth2_token') if not oauth1_json: logger.info("No OAuth1 token found in Consul") return False oauth1_token = json.loads(oauth1_json) oauth2_token = json.loads(oauth2_json) if oauth2_json else None garth.client.oauth1_token = oauth1_token if oauth2_token: garth.client.oauth2_token = oauth2_token logger.info("Successfully loaded Garmin tokens from Consul") return True except Exception as e: logger.warning(f"Failed to load garth tokens: {e}") return False async def upload_weight_data(self, records: List[WeightRecord]) -> Tuple[int, int]: """Upload weight records to Garmin""" config = self.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) if read_only_mode: logger.info(f"Read-only mode: Would upload {len(records)} weight records") 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(record) if success: success_count += 1 logger.info(f"Successfully uploaded: {record.weight_kg}kg at {record.timestamp}") else: logger.error(f"Failed to upload: {record.weight_kg}kg at {record.timestamp}") 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(self, record: WeightRecord) -> bool: """Upload weight using garminconnect library""" try: date_str = record.timestamp.strftime("%Y-%m-%d") logger.info(f"Uploading weight: {record.weight_kg}kg on {date_str}") timestamp_str = record.timestamp.isoformat() try: result = self.garmin_client.add_body_composition( timestamp=record.timestamp, weight=record.weight_kg ) except Exception as e1: try: result = self.garmin_client.add_body_composition( timestamp=timestamp_str, weight=record.weight_kg ) except Exception as e2: try: result = self.garmin_client.add_body_composition( timestamp=date_str, weight=record.weight_kg ) except Exception as e3: if hasattr(self.garmin_client, 'set_body_composition'): result = self.garmin_client.set_body_composition( timestamp=record.timestamp, weight=record.weight_kg ) elif 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") if result: logger.info("Upload successful") return True else: logger.error("Upload returned no result") return False except Exception as e: logger.error(f"Upload error: {e}") if "401" in str(e) or "unauthorized" in str(e).lower(): logger.error("Authentication failed - attempting re-authentication") try: self.garmin_client.login() self._save_garth_tokens() 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 except Exception as re_auth_error: logger.error(f"Re-authentication failed: {re_auth_error}") return False elif "429" in str(e) or "rate" in str(e).lower(): logger.error("Rate limit exceeded - wait 1-2 hours") return False elif "duplicate" in str(e).lower() or "already exists" in str(e).lower(): logger.warning(f"Weight already exists for {date_str}") return True return False class WeightSyncApp: """Main application class""" def __init__(self, consul_host: str = "consul.service.dc1.consul", consul_port: int = 8500, consul_prefix: str = "fitbit-garmin-sync"): self.consul = ConsulManager(consul_host, consul_port, consul_prefix) self.fitbit = FitbitClient(self.consul) self.garmin = GarminClient(self.consul) async def setup(self): """Setup and authenticate with services""" logger.info("Setting up Weight Sync Application...") if not await self.fitbit.authenticate(): logger.error("Failed to authenticate with Fitbit") return False if not await self.garmin.authenticate(): config = self.consul.get_config() if not config.get('sync', {}).get('read_only_mode', False): logger.error("Failed to authenticate with Garmin") return False logger.info("Setup completed successfully") return True async def sync_weight_data(self) -> bool: """Perform weight data synchronization""" try: logger.info("Starting weight data sync...") config = self.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) if read_only_mode: logger.info("Running in read-only mode") lookback_days = config.get('sync', {}).get('lookback_days', 7) end_date = datetime.now(timezone.utc) start_date = end_date - timedelta(days=lookback_days) fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) new_records = 0 for record in fitbit_records: if self.consul.save_weight_record(record): new_records += 1 logger.info(f"Processed {new_records} new weight records") unsynced_records = self.consul.get_unsynced_records() if not unsynced_records: logger.info("No unsynced records found") self.consul.log_sync("weight_sync", "success", "No records to sync", 0) return True success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) synced_count = 0 for i in range(success_count): record_to_mark = unsynced_records[i] if self.consul.mark_synced(record_to_mark.sync_id): synced_count += 1 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.consul.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.consul.log_sync("weight_sync", "error", error_msg, 0) return False async def force_full_sync(self, days: int = 365): """Perform full sync with custom lookback period""" try: logger.info(f"Starting FULL sync (looking back {days} days)...") config = self.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) if read_only_mode: logger.info("Running in read-only mode") 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()}") fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) if not fitbit_records: logger.warning("No weight records found") print("āŒ No weight records found") return False logger.info(f"Found {len(fitbit_records)} weight records") print(f"šŸ“Š Found {len(fitbit_records)} weight records") new_records = 0 for record in fitbit_records: if self.consul.save_weight_record(record): new_records += 1 print(f"šŸ’¾ Found {new_records} new records to sync") unsynced_records = self.consul.get_unsynced_records() if not unsynced_records: print("āœ… All records are already synced") return True print(f"šŸ”„ Found {len(unsynced_records)} records to sync to Garmin") success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) synced_count = 0 for i in range(success_count): record_to_mark = unsynced_records[i] if self.consul.mark_synced(record_to_mark.sync_id): synced_count += 1 mode_prefix = "(Read-only) " if read_only_mode else "" message = f"{mode_prefix}Full sync: {synced_count} synced, {failed_count} failed" status = "success" if failed_count == 0 else "partial" self.consul.log_sync("full_sync", status, message, synced_count) 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.consul.log_sync("full_sync", "error", error_msg, 0) print(f"āŒ Full sync failed: {e}") return False def reset_sync_status(self): """Reset all records to unsynced status""" try: affected_rows = self.consul.reset_sync_status() 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 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") def show_status(self): """Show application status""" try: config = self.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) status_info = self.consul.get_status_info() 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"Backend: Consul K/V Store") print(f"Total weight records: {status_info['total_records']}") print(f"Synced to Garmin: {status_info['synced_records']}") print(f"Pending sync: {status_info['unsynced_records']}") print(f"\nšŸ“œ Recent Sync History:") if status_info['recent_syncs']: for sync in status_info['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") if status_info['recent_records']: print(f"\nšŸ“ˆ Recent Weight Records:") for record in status_info['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""" config = self.consul.get_config() current_mode = config.get('sync', {}).get('read_only_mode', False) new_mode = not current_mode self.consul.update_config('sync', {'read_only_mode': new_mode}) 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'}") def start_scheduler(self): """Start the sync scheduler""" config = self.consul.get_config() sync_interval = config.get('sync', {}).get('sync_interval_minutes', 60) logger.info(f"Starting scheduler with {sync_interval} minute interval") schedule.every(sync_interval).minutes.do( lambda: asyncio.create_task(self.sync_weight_data()) ) asyncio.create_task(self.sync_weight_data()) while True: schedule.run_pending() time.sleep(60) async def main(): """Main application entry point""" import os # Get Consul connection details from environment or use defaults consul_host = os.getenv('CONSUL_HOST', 'consul.service.dc1.consul') consul_port = int(os.getenv('CONSUL_PORT', '8500')) consul_prefix = os.getenv('CONSUL_PREFIX', 'fitbit-garmin-sync') logger.info(f"Connecting to Consul at {consul_host}:{consul_port}") logger.info(f"Using Consul prefix: {consul_prefix}") app = WeightSyncApp(consul_host, consul_port, consul_prefix) 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 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 == "readonly": app.toggle_read_only_mode() elif command == "schedule": await app.setup() try: config = app.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) print("šŸš€ Starting scheduled sync...") if read_only_mode: print("šŸ“– Running in read-only mode") 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(" reset - Reset sync status for all records") print(" fullsync [days] - Full sync with custom lookback (default: 365)") print(" readonly - Toggle read-only mode") print(" schedule - Start scheduled sync") else: print("šŸƒ Weight Sync Application (Consul-Only)") print("Syncs weight data from Fitbit API to Garmin Connect") print("All state and configuration stored in Consul K/V store") print("\nRun with 'python fitbitsync.py '") print("\nAvailable commands:") print(" setup - Initial setup and authentication") print(" sync - Run manual sync") print(" status - Show sync status") print(" reset - Reset sync status for all records") print(" fullsync [days] - Full sync with custom lookback") print(" readonly - Toggle read-only mode") print(" schedule - Start scheduled sync") print("\nšŸ’” Tips:") print(" - All configuration is stored in Consul") print(" - Set CONSUL_HOST, CONSUL_PORT, CONSUL_PREFIX env vars to override defaults") print(" - Use 'readonly' to toggle between read-only and full sync mode") print(" - First run 'setup' to configure API credentials") config = app.consul.get_config() read_only_mode = config.get('sync', {}).get('read_only_mode', False) if read_only_mode: print("\nšŸ“– Currently in READ-ONLY mode") else: print("\nšŸ”„ Currently in FULL SYNC mode") if __name__ == "__main__": asyncio.run(main())