From 950580a80f994c0b3cd83b88ac1c3127a6da21ed Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 14 Dec 2025 11:49:26 -0800 Subject: [PATCH] sync --- README.md | 58 +- fitbit-garmin-sync.nomad | 4 +- fitbitsync.py | 456 ++++++++------- fitbitsync_debug.py | 1159 ++++++++++++-------------------------- requirements.txt | 1 + 5 files changed, 661 insertions(+), 1017 deletions(-) diff --git a/README.md b/README.md index 0def727..3c0cc9f 100644 --- a/README.md +++ b/README.md @@ -16,32 +16,33 @@ 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. +The application is configured entirely via Consul. You can specify the Consul agent's location using environment variables. -1. **Create a data directory on your host machine:** +- `CONSUL_HOST`: The hostname or IP address of your Consul agent (defaults to `consul.service.dc1.consul`). +- `CONSUL_PORT`: The port of your Consul agent (defaults to `8500`). - ```bash - mkdir fitbit_garmin_data - ``` +The application can be run in several modes. The default command is `schedule` to run the sync on a schedule. -2. **Run the Docker container with a mounted volume:** +```bash +docker run -d --name fitbit-sync \ + -e CONSUL_HOST=your-consul-host \ + -e CONSUL_PORT=8500 \ + fitbit-garmin-sync +``` - 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. +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. +The first time you run the application, you will need to perform an interactive setup to provide your Fitbit and Garmin credentials. These will be stored securely in Consul. 1. **Run the container with the `setup` command:** ```bash - docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync setup + docker run -it --rm \ + -e CONSUL_HOST=your-consul-host \ + -e CONSUL_PORT=8500 \ + fitbit-garmin-sync setup ``` - `-it` allows you to interact with the container's terminal. @@ -49,33 +50,28 @@ The first time you run the application, you will need to perform an interactive 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. + After the setup is complete, the necessary configuration and session data will be saved in Consul. ### 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 +docker run -it --rm \ + -e CONSUL_HOST=your-consul-host \ + -e CONSUL_PORT=8500 \ + fitbit-garmin-sync sync ``` To check the status: ```bash -docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync status +docker run -it --rm \ + -e CONSUL_HOST=your-consul-host \ + -e CONSUL_PORT=8500 \ + fitbit-garmin-sync status ``` -## Data Persistence +## Configuration in Consul -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 +All application state, including credentials, tokens, and sync status, is stored in Consul under a configurable prefix (default: `fitbit-garmin-sync`). \ No newline at end of file diff --git a/fitbit-garmin-sync.nomad b/fitbit-garmin-sync.nomad index 9ff3783..cfcbbd8 100644 --- a/fitbit-garmin-sync.nomad +++ b/fitbit-garmin-sync.nomad @@ -9,7 +9,7 @@ job "fitbit-garmin-sync" { driver = "docker" config { - image = "gitea.service.dc1.fbleagh.duckdns.org/sstent/fitbit-garmin-sync:latest" + image = "gitea.service.dc1.fbleagh.duckdns.org/sstent/fitbit_garmin_sync:latest" volumes = [ "/mnt/Public/configs/fitbit-garmin-sync:/app/data" ] @@ -26,4 +26,4 @@ job "fitbit-garmin-sync" { # The command to run is defined in the Dockerfile ENTRYPOINT and CMD. } } -} \ No newline at end of file +} diff --git a/fitbitsync.py b/fitbitsync.py index 17f5aa4..a5ee8c5 100644 --- a/fitbitsync.py +++ b/fitbitsync.py @@ -4,7 +4,6 @@ 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 @@ -33,6 +32,12 @@ try: except ImportError: GARMINCONNECT_LIBRARY = False +try: + import consul + CONSUL_LIBRARY = True +except ImportError: + CONSUL_LIBRARY = False + import schedule # Configure logging @@ -112,8 +117,10 @@ class ConfigManager: "max_retries": 3, "read_only_mode": False # Set to True to prevent uploads to Garmin }, - "database": { - "path": "weight_sync.db" + "consul": { + "host": "consul.service.dc1.consul", + "port": 8500, + "prefix": "fitbit-garmin-sync" } } # Don't automatically save here, let the caller decide @@ -166,114 +173,208 @@ class ConfigManager: 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)') +class ConsulStateManager: + """Manages sync state and records using Consul K/V store""" + def __init__(self, config: ConfigManager): + if not CONSUL_LIBRARY: + raise ImportError("python-consul library not installed. Please install it with: pip install python-consul") + + consul_config = config.get('consul') + self.client = consul.Consul( + host=consul_config.get('host', 'localhost'), + port=consul_config.get('port', 8500) + ) + self.prefix = consul_config.get('prefix', 'fitbit-garmin-sync').strip('/') + self.records_prefix = f"{self.prefix}/records/" + self.logs_prefix = f"{self.prefix}/logs/" + + logger.info(f"Using Consul for state management at {consul_config.get('host')}:{consul_config.get('port')} with prefix '{self.prefix}'") + def save_weight_record(self, record: WeightRecord) -> bool: - """Save weight record to database""" + """Save weight record to Consul if it doesn't exist.""" + key = f"{self.records_prefix}{record.sync_id}" 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() - )) + # Check if record already exists + index, data = self.client.kv.get(key) + if data is not None: + # Record already exists, no need to save again + return False + + # Record doesn't exist, save it with synced_to_garmin=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: {e}") + logger.error(f"Error saving weight record to Consul: {e}") return False - + def get_unsynced_records(self) -> List[WeightRecord]: - """Get records that haven't been synced to Garmin""" + """Get records from Consul 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) + # This is inefficient and not recommended for large datasets + 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. This may be slow.") + + 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 Consul at key {key}: {e}") except Exception as e: - logger.error(f"Error getting unsynced records: {e}") + logger.error(f"Error getting unsynced records from Consul: {e}") + # Sort by timestamp descending to sync newest records first + 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""" + """Mark a record as synced to Garmin in Consul.""" + key = f"{self.records_prefix}{sync_id}" 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}") + # Use a Check-And-Set (CAS) loop for safe updates + for _ in range(5): # Max 5 retries + 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 in Consul.") + return False + + record_data = json.loads(data['Value']) + record_data['synced_to_garmin'] = True + + # The 'cas' parameter ensures we only update if the key hasn't changed + success = self.client.kv.put(key, json.dumps(record_data), cas=data['ModifyIndex']) + if success: + return True + time.sleep(0.1) # Wait a bit before retrying + + logger.error(f"Failed to mark record {sync_id} as synced after multiple retries.") 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}") + logger.error(f"Error marking record as synced in Consul: {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 to Consul: {e}") + + def reset_sync_status(self) -> int: + """Reset all records to unsynced status in Consul.""" + 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 in Consul...") + + for key in keys: + try: + # Use CAS loop for safety + 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 # Already unsynced + 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 in Consul: {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: + # Get record counts + 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 + + # Get recent records + 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'] + )) + + # Get recent sync logs + index, log_keys = self.client.kv.get(self.logs_prefix, keys=True) + if log_keys: + log_keys.sort(reverse=True) # Sort by timestamp descending + 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 from Consul: {e}") + + return status_info class FitbitClient: """Client for Fitbit API using python-fitbit""" @@ -881,12 +982,7 @@ class WeightSyncApp: def __init__(self, config_file: str = "data/config.json"): self.config = ConfigManager(config_file) - - # 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.state = ConsulStateManager(self.config) self.fitbit = FitbitClient(self.config) self.garmin = GarminClient(self.config) @@ -933,20 +1029,17 @@ class WeightSyncApp: 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 + # Save new records to state manager new_records = 0 - updated_records = 0 for record in fitbit_records: - if self.db.save_weight_record(record): + if self.state.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") + logger.info(f"Processed {new_records} new weight records") + print(f"šŸ’¾ Found {new_records} new records to potentially sync") # Get unsynced records - unsynced_records = self.db.get_unsynced_records() + unsynced_records = self.state.get_unsynced_records() if not unsynced_records: logger.info("No unsynced records found") @@ -960,15 +1053,17 @@ class WeightSyncApp: # Mark successful uploads as synced synced_count = 0 - for record in unsynced_records[:success_count]: - if self.db.mark_synced(record.sync_id): + # Iterate over the original list but only up to the number of successes + for i in range(success_count): + record_to_mark = unsynced_records[i] + if self.state.mark_synced(record_to_mark.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) + self.state.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") @@ -977,7 +1072,7 @@ class WeightSyncApp: 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) + self.state.log_sync("full_sync", "error", error_msg, 0) print(f"āŒ Full sync failed: {e}") return False @@ -1036,17 +1131,11 @@ class WeightSyncApp: 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 + affected_rows = self.state.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}") @@ -1071,20 +1160,20 @@ class WeightSyncApp: # Fetch data from Fitbit fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) - # Save new records to database + # Save new records to state manager new_records = 0 for record in fitbit_records: - if self.db.save_weight_record(record): + if self.state.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() + unsynced_records = self.state.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) + self.state.log_sync("weight_sync", "success", "No records to sync", 0) return True # Upload to Garmin (or simulate in read-only mode) @@ -1092,15 +1181,17 @@ class WeightSyncApp: # 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): + # Iterate over the original list but only up to the number of successes + for i in range(success_count): + record_to_mark = unsynced_records[i] + if self.state.mark_synced(record_to_mark.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) + self.state.log_sync("weight_sync", status, message, synced_count) logger.info(f"Sync completed: {message}") return True @@ -1108,7 +1199,7 @@ class WeightSyncApp: except Exception as e: error_msg = f"Sync failed: {e}" logger.error(error_msg) - self.db.log_sync("weight_sync", "error", error_msg, 0) + self.state.log_sync("weight_sync", "error", error_msg, 0) return False def start_scheduler(self): @@ -1142,65 +1233,45 @@ class WeightSyncApp: """Show application status""" try: read_only_mode = self.config.get('sync.read_only_mode', False) + status_info = self.state.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' if CONSUL_LIBRARY else 'Unknown'}") + print(f"Fitbit Library: {'Available' if FITBIT_LIBRARY else 'Not Available'}") + print(f"Garmin Library: {'Available' if GARMINCONNECT_LIBRARY else 'Not Available'}") + 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']}") - 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]})") + 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") + + # 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}") + + if status_info['recent_records']: + print(f"\nšŸ“ˆ Recent Weight Records (from Consul):") + 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}") @@ -1281,9 +1352,10 @@ async def main(): elif command == "config": read_only_mode = app.config.get('sync.read_only_mode', False) + consul_config = app.config.get('consul') 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"šŸ”— Consul K/V Prefix: {consul_config.get('prefix')} at {consul_config.get('host')}:{consul_config.get('port')}") + print(f"šŸ“ Log file: data/weight_sync.log") print(f"šŸ”’ Read-only mode: {'Enabled' if read_only_mode else 'Disabled'}") elif command == "readonly": diff --git a/fitbitsync_debug.py b/fitbitsync_debug.py index 90298e1..123a49c 100644 --- a/fitbitsync_debug.py +++ b/fitbitsync_debug.py @@ -1,11 +1,9 @@ - -# Fitbit to Garmin Weight Sync Application - Debug Version -# Adds detailed logging for Garmin authentication issues +# Fitbit to Garmin Weight Sync Application +# Syncs weight data from Fitbit API to Garmin Connect import asyncio import json import logging -import sqlite3 from datetime import datetime, timedelta, timezone from typing import List, Dict, Optional, Tuple from dataclasses import dataclass, asdict @@ -15,6 +13,7 @@ import time import os import webbrowser from urllib.parse import urlparse, parse_qs +import tempfile try: import fitbit @@ -23,19 +22,30 @@ except ImportError: FITBIT_LIBRARY = False try: - import garminconnect - GARMIN_LIBRARY = "garminconnect" + import garth + GARTH_LIBRARY = True except ImportError: - GARMIN_LIBRARY = None + 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 with DEBUG level +# Configure logging logging.basicConfig( - level=logging.DEBUG, # Changed to DEBUG for detailed logging + level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ - logging.FileHandler('weight_sync_debug.log'), logging.StreamHandler() ] ) @@ -55,219 +65,356 @@ class WeightRecord: unique_string = f"{self.timestamp.isoformat()}_{self.weight_kg}" self.sync_id = hashlib.md5(unique_string.encode()).hexdigest() +def _flatten_dict(d, parent_key='', sep='/'): + """Flattens a nested dictionary for Consul K/V storage.""" + items = [] + for k, v in d.items(): + new_key = parent_key + sep + k if parent_key else k + if isinstance(v, dict): + items.extend(_flatten_dict(v, new_key, sep=sep).items()) + else: + items.append((new_key, v)) + return dict(items) + +def _unflatten_dict(d, sep='/'): + """Unflattens a dictionary from Consul K/V into a nested structure.""" + result = {} + for key, value in d.items(): + parts = key.split(sep) + d_ref = result + for part in parts[:-1]: + if part not in d_ref: + d_ref[part] = {} + d_ref = d_ref[part] + d_ref[parts[-1]] = value + return result + class ConfigManager: - """Manages application configuration and credentials""" + """Manages application configuration and credentials using Consul.""" - def __init__(self, config_file: str = "config.json"): - self.config_file = Path(config_file) + def __init__(self): + if not CONSUL_LIBRARY: + raise ImportError("python-consul library is not installed. Please install it with: pip install python-consul") + + self.consul_host = os.getenv('CONSUL_HOST', 'consul.service.dc1.consul') + self.consul_port = int(os.getenv('CONSUL_PORT', '8500')) + + self.client = consul.Consul(host=self.consul_host, port=self.consul_port) + + index, prefix_data = self.client.kv.get('fitbit-garmin-sync/prefix') + self.prefix = prefix_data['Value'].decode() if prefix_data else 'fitbit-garmin-sync' + + self.config_prefix = f"{self.prefix}/config/" + + logger.info(f"Using Consul for config management at {self.consul_host}:{self.consul_port} with prefix '{self.config_prefix}'") + self.config = self._load_config() def _load_config(self) -> Dict: - """Load configuration from file""" - if self.config_file.exists(): - try: - with open(self.config_file, 'r') as f: - config = json.load(f) - # Ensure all required sections exist - default_config = self._create_default_config() - for section, defaults in default_config.items(): - if section not in config: - config[section] = defaults - elif isinstance(defaults, dict): - for key, default_value in defaults.items(): - if key not in config[section]: - config[section][key] = default_value - return config - except Exception as e: - logger.warning(f"Error loading config file: {e}") - return self._create_default_config() - return self._create_default_config() + """Load configuration from Consul K/V.""" + default_config = self._create_default_config() + + try: + index, kv_pairs = self.client.kv.get(self.config_prefix, recurse=True) + if kv_pairs is None: + logger.info("No configuration found in Consul. Using defaults and saving.") + self.save_config(default_config) + return default_config + + consul_config_flat = { + item['Key'][len(self.config_prefix):]: item['Value'].decode() + for item in kv_pairs + } + consul_config = _unflatten_dict(consul_config_flat) + + merged_config = default_config.copy() + for section, defaults in default_config.items(): + if section in consul_config: + if isinstance(defaults, dict): + merged_config[section] = defaults.copy() + for key, val in consul_config[section].items(): + merged_config[section][key] = val + else: + merged_config[section] = consul_config[section] + + return merged_config + + except Exception as e: + logger.warning(f"Error loading config from Consul: {e}. Falling back to defaults.") + return default_config def _create_default_config(self) -> Dict: - """Create default configuration""" - config = { + """Create default configuration structure.""" + return { "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" + "is_china": "False" }, "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" + "sync_interval_minutes": "60", + "lookback_days": "7", + "max_retries": "3", + "read_only_mode": "False" } } - # Don't automatically save here, let the caller decide - return config def save_config(self, config: Dict = None): - """Save configuration to file""" + """Save configuration to Consul K/V.""" if config: self.config = config - with open(self.config_file, 'w') as f: - json.dump(self.config, f, indent=2) + + try: + flat_config = _flatten_dict(self.config) + for key, value in flat_config.items(): + consul_key = f"{self.config_prefix}{key}" + self.client.kv.put(consul_key, str(value)) + logger.info("Successfully saved configuration to Consul.") + except Exception as e: + logger.error(f"Error saving configuration to Consul: {e}") def get(self, key: str, default=None): - """Get configuration value using dot notation""" + """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 + if isinstance(value, dict): + value = value.get(k) + if value is None: + return default + else: + return default + + if isinstance(value, str): + if value.lower() in ['true', 'false']: + return value.lower() == 'true' + if value.isdigit(): + return int(value) + + return value if value is not None else default def set_credentials(self, service: str, **kwargs): - """Store credentials in config file""" - if service == "garmin": - # Ensure garmin section exists - if "garmin" not in self.config: - self.config["garmin"] = {} - self.config["garmin"]["username"] = kwargs.get("username", "") - self.config["garmin"]["password"] = kwargs.get("password", "") - elif service == "fitbit": - # Ensure fitbit section exists - if "fitbit" not in self.config: - self.config["fitbit"] = { - "client_id": "", - "client_secret": "", - "access_token": "", - "refresh_token": "", - "token_file": "fitbit_token.json", - "redirect_uri": "http://localhost:8080/fitbit-callback" - } - - for key, value in kwargs.items(): - if key in self.config["fitbit"]: - self.config["fitbit"][key] = value + """Store credentials in config and save to Consul.""" + if service not in self.config: + self.config[service] = {} + + for key, value in kwargs.items(): + self.config[service][key] = value self.save_config() def get_credentials(self, service: str, field: str) -> Optional[str]: - """Retrieve stored credentials from config""" - if service == "garmin": - return self.config.get("garmin", {}).get(field) - elif service == "fitbit": - return self.config.get("fitbit", {}).get(field) + """Retrieve stored credentials from config.""" + return self.config.get(service, {}).get(field) -class DatabaseManager: - """Manages SQLite database for sync state and records""" +class ConsulStateManager: + """Manages sync state and records using Consul K/V store""" - 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""" + def __init__(self, config: ConfigManager): + self.client = config.client + self.prefix = config.prefix + self.records_prefix = f"{self.prefix}/records/" + self.logs_prefix = f"{self.prefix}/logs/" + self.garmin_session_key = f"{self.prefix}/garmin_session" + + logger.info(f"Using Consul for state management with prefix '{self.prefix}'") + + def get_garmin_session(self) -> Optional[Dict]: + """Gets the Garmin session data from Consul.""" 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() - )) + index, data = self.client.kv.get(self.garmin_session_key) + if data: + return json.loads(data['Value']) + except Exception as e: + logger.error(f"Error getting Garmin session from Consul: {e}") + return None + + def save_garmin_session(self, session_data: Dict): + """Saves the Garmin session data to Consul.""" + try: + self.client.kv.put(self.garmin_session_key, json.dumps(session_data)) + except Exception as e: + logger.error(f"Error saving Garmin session to Consul: {e}") + + 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: {e}") + logger.error(f"Error saving weight record to Consul: {e}") return False - + def get_unsynced_records(self) -> List[WeightRecord]: - """Get records that haven't been synced to Garmin""" + """Get records from Consul 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) + 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. This may be slow.") + + 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 Consul at key {key}: {e}") except Exception as e: - logger.error(f"Error getting unsynced records: {e}") + logger.error(f"Error getting unsynced records from Consul: {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""" + """Mark a record as synced to Garmin in Consul.""" + key = f"{self.records_prefix}{sync_id}" 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}") + 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 in Consul.") + 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 multiple retries.") 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}") + logger.error(f"Error marking record as synced in Consul: {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 to Consul: {e}") + + def reset_sync_status(self) -> int: + """Reset all records to unsynced status in Consul.""" + 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 in Consul...") + + 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 in Consul: {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 from Consul: {e}") + + return status_info class FitbitClient: """Client for Fitbit API using python-fitbit""" @@ -279,7 +426,6 @@ class FitbitClient: 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 @@ -297,16 +443,13 @@ class FitbitClient: 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, @@ -316,17 +459,13 @@ class FitbitClient: 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: @@ -358,7 +497,6 @@ class FitbitClient: 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") @@ -369,7 +507,6 @@ class FitbitClient: try: redirect_uri = self.config.get('fitbit.redirect_uri') - # Create Fitbit client for OAuth from fitbit.api import FitbitOauth2Client auth_client = FitbitOauth2Client( @@ -378,7 +515,6 @@ class FitbitClient: redirect_uri=redirect_uri ) - # Get authorization URL auth_url, _ = auth_client.authorize_token_url() print("\nšŸ” Fitbit OAuth Authorization") @@ -389,20 +525,17 @@ class FitbitClient: 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) @@ -414,17 +547,14 @@ class FitbitClient: 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, @@ -433,7 +563,6 @@ class FitbitClient: 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']}") @@ -457,7 +586,6 @@ class FitbitClient: ) 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 [] @@ -467,11 +595,9 @@ class FitbitClient: 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 @@ -479,17 +605,12 @@ class FitbitClient: 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())}") @@ -498,18 +619,15 @@ class FitbitClient: 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 + weight_kg = weight_lbs * 0.453592 record = WeightRecord( timestamp=timestamp, @@ -518,7 +636,7 @@ class FitbitClient: ) records.append(record) - logger.info(f"Found weight record: {weight_lbs}lbs ({weight_kg:.1f}kg) at {timestamp}") + logger.debug(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}") @@ -538,32 +656,32 @@ class FitbitClient: class GarminClient: """Client for Garmin Connect using garminconnect library""" - def __init__(self, config: ConfigManager): + def __init__(self, config: ConfigManager, state: ConsulStateManager): self.config = config + self.state = state 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""" + """Authenticate with Garmin Connect using garth, with session managed in Consul.""" if self.read_only_mode: logger.info("Running in read-only mode - skipping Garmin authentication") return True + temp_session_file = None try: - # Get credentials from config self.username = self.config.get_credentials('garmin', 'username') self.password = self.config.get_credentials('garmin', 'password') @@ -571,53 +689,31 @@ class GarminClient: logger.info("No stored Garmin credentials found. Please set them up.") if not self._setup_credentials(): return False + + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".json") as f: + temp_session_file = f.name + session_data = self.state.get_garmin_session() + if session_data: + logger.info("Found Garmin session in Consul, writing to temporary file.") + json.dump(session_data, f) + else: + logger.info("No Garmin session found in Consul.") - logger.info("Initializing Garmin client...") - self.garmin_client = self.garminconnect.Garmin( - self.username, - self.password, - is_cn=self.is_china - ) + os.environ['GARMINTOKENS'] = temp_session_file - # 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.") + if self.is_china: + garth.configure(domain="garmin.cn") - # 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 + self.garmin_client = self.garminconnect.Garmin(self.username, self.password) + self.garmin_client.login() - # Save the session using garth's dump method - self.garmin_client.garth.dump(self.session_file) + with open(temp_session_file, 'r') as f: + updated_session_data = json.load(f) + self.state.save_garmin_session(updated_session_data) + logger.info("Saved updated Garmin session to Consul.") profile = self.garmin_client.get_full_name() - logger.info(f"Successfully authenticated and saved session for user: {profile}") + logger.info(f"Successfully authenticated with Garmin for user: {profile}") return True except Exception as e: @@ -625,6 +721,13 @@ class GarminClient: import traceback logger.error(f"Full traceback: {traceback.format_exc()}") return False + finally: + if temp_session_file and os.path.exists(temp_session_file): + os.remove(temp_session_file) + + def _mfa_handler(self, _) -> str: + """Handle MFA code input from the user.""" + return input("Enter Garmin MFA code: ") def _setup_credentials(self) -> bool: """Setup Garmin credentials interactively""" @@ -642,7 +745,6 @@ class GarminClient: print("āŒ Password cannot be empty") return False - # Store credentials in config self.config.set_credentials('garmin', username=username, password=password) self.username = username @@ -676,7 +778,6 @@ class GarminClient: 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: @@ -685,68 +786,26 @@ class GarminClient: 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 - ) - + 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 - ) - + 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 - ) - + 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 + return False if result: logger.info(f"garminconnect upload successful") @@ -758,49 +817,20 @@ class GarminClient: 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 + await self.authenticate() + return False 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") + logger.warning(f"Weight already exists for {record.timestamp.strftime('%Y-%m-%d')}") 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 [] @@ -810,15 +840,10 @@ class GarminClient: 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 - ) + weights = self.garmin_client.get_body_composition(startdate=start_date, enddate=end_date) return weights if weights else [] @@ -827,7 +852,6 @@ class GarminClient: 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") @@ -843,31 +867,19 @@ class GarminClient: 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) - ) + 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 + if weight_kg > 300 or weight_kg < 30: 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}") @@ -877,16 +889,7 @@ class GarminClient: 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}") @@ -894,22 +897,20 @@ class GarminClient: 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')) + def __init__(self): + self.config = ConfigManager() + self.state = ConsulStateManager(self.config) self.fitbit = FitbitClient(self.config) - self.garmin = GarminClient(self.config) + self.garmin = GarminClient(self.config, self.state) 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") @@ -917,429 +918,3 @@ class WeightSyncApp: logger.info("Setup completed successfully") return True - - async def force_full_sync(self, days: int = 365): - """Perform full sync with custom lookback period""" - try: - logger.info(f"Starting FULL weight data sync (looking back {days} days)...") - - read_only_mode = self.config.get('sync.read_only_mode', False) - if read_only_mode: - logger.info("Running in read-only mode - will not upload to Garmin") - - # Get extended date range for sync - end_date = datetime.now(timezone.utc) - start_date = end_date - timedelta(days=days) - - logger.info(f"Fetching Fitbit data from {start_date.date()} to {end_date.date()}") - - # Fetch data from Fitbit - fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) - - if not fitbit_records: - logger.warning("No weight records found in Fitbit for the specified period") - print("āŒ No weight records found in Fitbit for the specified period") - return False - - logger.info(f"Found {len(fitbit_records)} weight records from Fitbit") - print(f"šŸ“Š Found {len(fitbit_records)} weight records from Fitbit") - - # Save new records to database - new_records = 0 - updated_records = 0 - for record in fitbit_records: - if self.db.save_weight_record(record): - new_records += 1 - else: - updated_records += 1 - - logger.info(f"Processed {new_records} new weight records, {updated_records} updated records") - print(f"šŸ’¾ Processed {new_records} new records, {updated_records} updated records") - - # Get unsynced records - unsynced_records = self.db.get_unsynced_records() - - if not unsynced_records: - logger.info("No unsynced records found") - print("āœ… All records are already synced") - return True - - print(f"šŸ”„ Found {len(unsynced_records)} records to sync to Garmin") - - # Upload to Garmin (or simulate in read-only mode) - success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) - - # Mark successful uploads as synced - synced_count = 0 - for record in unsynced_records[:success_count]: - if self.db.mark_synced(record.sync_id): - synced_count += 1 - - # Log results - mode_prefix = "(Read-only) " if read_only_mode else "" - message = f"{mode_prefix}Full sync: {synced_count} records synced, {failed_count} failed" - status = "success" if failed_count == 0 else "partial" - self.db.log_sync("full_sync", status, message, synced_count) - - logger.info(f"Full sync completed: {message}") - print(f"āœ… Full sync completed: {synced_count} synced, {failed_count} failed") - return True - - except Exception as e: - error_msg = f"Full sync failed: {e}" - logger.error(error_msg) - self.db.log_sync("full_sync", "error", error_msg, 0) - print(f"āŒ Full sync failed: {e}") - return False - - async def debug_fitbit_data(self, days: int = 30): - """Debug Fitbit data retrieval""" - try: - logger.info("Setting up Fitbit client for debugging...") - if not await self.fitbit.authenticate(): - print("āŒ Failed to authenticate with Fitbit") - return - - end_date = datetime.now(timezone.utc) - start_date = end_date - timedelta(days=days) - - print(f"šŸ” Checking Fitbit data from {start_date.date()} to {end_date.date()}") - - # Raw API call to see what we get - if self.fitbit.client: - try: - start_date_str = start_date.strftime("%Y-%m-%d") - end_date_str = end_date.strftime("%Y-%m-%d") - - print(f"šŸ“” Making API call: get_bodyweight({start_date_str}, {end_date_str})") - - weight_data = self.fitbit.client.get_bodyweight( - base_date=start_date_str, - end_date=end_date_str - ) - - print(f"šŸ“„ Raw Fitbit API response:") - print(json.dumps(weight_data, indent=2)) - - # Also try individual day calls - print(f"\nšŸ“… Trying individual day calls for last 7 days:") - for i in range(7): - check_date = end_date - timedelta(days=i) - date_str = check_date.strftime("%Y-%m-%d") - try: - daily_data = self.fitbit.client.get_bodyweight(base_date=date_str) - if daily_data and daily_data.get('body-weight'): - print(f" {date_str}: {daily_data['body-weight']}") - else: - print(f" {date_str}: No data") - except Exception as e: - print(f" {date_str}: Error - {e}") - - except Exception as e: - print(f"āŒ API call failed: {e}") - import traceback - print(f"Full traceback: {traceback.format_exc()}") - - except Exception as e: - print(f"āŒ Debug failed: {e}") - - def reset_sync_status(self): - """Reset all records to unsynced status""" - try: - with sqlite3.connect(self.db.db_path) as conn: - result = conn.execute(''' - UPDATE weight_records - SET synced_to_garmin = FALSE, updated_at = ? - ''', (datetime.now().isoformat(),)) - - affected_rows = result.rowcount - logger.info(f"Reset sync status for {affected_rows} records") - print(f"šŸ”„ Reset sync status for {affected_rows} records") - print(" All records will be synced again on next sync") - return True - - except Exception as e: - logger.error(f"Error resetting sync status: {e}") - print(f"āŒ Error resetting sync status: {e}") - return False - - async def sync_weight_data(self) -> bool: - """Perform weight data synchronization""" - try: - logger.info("Starting weight data sync...") - - read_only_mode = self.config.get('sync.read_only_mode', False) - if read_only_mode: - logger.info("Running in read-only mode - will not upload to Garmin") - - # Get date range for sync - lookback_days = self.config.get('sync.lookback_days', 7) - end_date = datetime.now(timezone.utc) - start_date = end_date - timedelta(days=lookback_days) - - # Fetch data from Fitbit - fitbit_records = await self.fitbit.get_weight_data(start_date, end_date) - - # Save new records to database - new_records = 0 - for record in fitbit_records: - if self.db.save_weight_record(record): - new_records += 1 - - logger.info(f"Processed {new_records} new weight records from Fitbit") - - # Get unsynced records - unsynced_records = self.db.get_unsynced_records() - - if not unsynced_records: - logger.info("No unsynced records found") - self.db.log_sync("weight_sync", "success", "No records to sync", 0) - return True - - # Upload to Garmin (or simulate in read-only mode) - success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records) - - # Mark successful uploads as synced (even in read-only mode for simulation) - synced_count = 0 - for record in unsynced_records[:success_count]: - if self.db.mark_synced(record.sync_id): - synced_count += 1 - - # Log results - mode_prefix = "(Read-only) " if read_only_mode else "" - message = f"{mode_prefix}Synced {synced_count} records, {failed_count} failed" - status = "success" if failed_count == 0 else "partial" - self.db.log_sync("weight_sync", status, message, synced_count) - - logger.info(f"Sync completed: {message}") - return True - - except Exception as e: - error_msg = f"Sync failed: {e}" - logger.error(error_msg) - self.db.log_sync("weight_sync", "error", error_msg, 0) - return False - - def start_scheduler(self): - """Start the sync scheduler""" - sync_interval = self.config.get('sync.sync_interval_minutes', 60) - - logger.info(f"Starting scheduler with {sync_interval} minute interval") - - # Schedule sync - schedule.every(sync_interval).minutes.do( - lambda: asyncio.create_task(self.sync_weight_data()) - ) - - # Run initial sync - asyncio.create_task(self.sync_weight_data()) - - # Keep scheduler running - while True: - schedule.run_pending() - time.sleep(60) # Check every minute - - async def manual_sync(self): - """Perform manual sync""" - success = await self.sync_weight_data() - if success: - print("āœ… Manual sync completed successfully") - else: - print("āŒ Manual sync failed - check logs for details") - - def show_status(self): - """Show application status""" - try: - read_only_mode = self.config.get('sync.read_only_mode', False) - - with sqlite3.connect(self.db.db_path) as conn: - # Get record counts - total_records = conn.execute("SELECT COUNT(*) FROM weight_records").fetchone()[0] - synced_records = conn.execute("SELECT COUNT(*) FROM weight_records WHERE synced_to_garmin = TRUE").fetchone()[0] - unsynced_records = total_records - synced_records - - # Get recent sync logs - recent_syncs = conn.execute(''' - SELECT timestamp, status, message, records_processed - FROM sync_log - ORDER BY timestamp DESC - LIMIT 5 - ''').fetchall() - - print("\nšŸ“Š Weight Sync Status") - print("=" * 50) - print(f"Mode: {'Read-only (No Garmin uploads)' if read_only_mode else 'Full sync mode'}") - print(f"Fitbit Library: {'Available' if FITBIT_LIBRARY else 'Not Available'}") - print(f"Garmin Library: {GARMIN_LIBRARY or 'Not Available'}") - print(f"Total weight records: {total_records}") - print(f"Synced to Garmin: {synced_records}") - print(f"Pending sync: {unsynced_records}") - - print(f"\nšŸ“ Recent Sync History:") - if recent_syncs: - for sync in recent_syncs: - status_emoji = "āœ…" if sync[1] == "success" else "āš ļø" if sync[1] == "partial" else "āŒ" - print(f" {status_emoji} {sync[0]} - {sync[1]} - {sync[2]} ({sync[3]} records)") - else: - print(" No sync history found") - - # Show recent Garmin weights if available and not in read-only mode - if not read_only_mode: - try: - recent_weights = self.garmin.get_recent_weights(7) - if recent_weights: - print(f"\nāš–ļø Recent Garmin Weights:") - for weight in recent_weights[:5]: # Show last 5 - date = weight.get('calendarDate', 'Unknown') - weight_kg = weight.get('weight', 0) / 1000 if weight.get('weight') else 'Unknown' - print(f" šŸ“… {date}: {weight_kg}kg") - except Exception as e: - logger.debug(f"Could not fetch recent Garmin weights: {e}") - - # Show recent database records - recent_records = conn.execute(''' - SELECT timestamp, weight_kg, source, synced_to_garmin - FROM weight_records - ORDER BY timestamp DESC - LIMIT 5 - ''').fetchall() - - if recent_records: - print(f"\nšŸ“ˆ Recent Weight Records:") - for record in recent_records: - sync_status = "āœ…" if record[3] else "ā³" - timestamp = datetime.fromisoformat(record[0]) - print(f" {sync_status} {timestamp.strftime('%Y-%m-%d %H:%M')}: {record[1]}kg ({record[2]})") - - except Exception as e: - print(f"āŒ Error getting status: {e}") - - def toggle_read_only_mode(self): - """Toggle read-only mode""" - current_mode = self.config.get('sync.read_only_mode', False) - new_mode = not current_mode - - self.config.config['sync']['read_only_mode'] = new_mode - self.config.save_config() - - mode_text = "enabled" if new_mode else "disabled" - print(f"āœ… Read-only mode {mode_text}") - print(f" {'Will NOT upload to Garmin' if new_mode else 'Will upload to Garmin'}") - -async def main(): - """Main application entry point""" - import sys - - app = WeightSyncApp() - - if len(sys.argv) > 1: - command = sys.argv[1].lower() - - if command == "setup": - success = await app.setup() - if success: - print("āœ… Setup completed successfully") - else: - print("āŒ Setup failed") - - elif command == "sync": - await app.setup() - await app.manual_sync() - - elif command == "status": - app.show_status() - - elif command == "reset": - app.reset_sync_status() - - elif command == "fullsync": - days = 365 # Default to 1 year - if len(sys.argv) > 2: - try: - days = int(sys.argv[2]) - except ValueError: - print("āŒ Invalid number of days. Using default 365.") - - await app.setup() - await app.force_full_sync(days) - - elif command == "check": - await app.setup() - app.garmin.check_garmin_weights(30) - - elif command == "testupload": - # Test upload with a single fake record - await app.setup() - test_record = WeightRecord( - timestamp=datetime.now(timezone.utc), - weight_kg=70.0, # 70kg test weight - source="test" - ) - success, failed = await app.garmin.upload_weight_data([test_record]) - print(f"Test upload: {success} successful, {failed} failed") - - elif command == "debug": - days = 30 - if len(sys.argv) > 2: - try: - days = int(sys.argv[2]) - except ValueError: - print("āŒ Invalid number of days. Using default 30.") - await app.debug_fitbit_data(days) - - elif command == "config": - read_only_mode = app.config.get('sync.read_only_mode', False) - print(f"šŸ“ Configuration file: {app.config.config_file}") - print(f"šŸ“ Database file: {app.config.get('database.path')}") - print(f"šŸ“ Log file: weight_sync.log") - print(f"šŸ”’ Read-only mode: {'Enabled' if read_only_mode else 'Disabled'}") - - elif command == "readonly": - app.toggle_read_only_mode() - - elif command == "schedule": - await app.setup() - try: - read_only_mode = app.config.get('sync.read_only_mode', False) - print("šŸš€ Starting scheduled sync...") - if read_only_mode: - print("šŸ“– Running in read-only mode - will NOT upload to Garmin") - print("Press Ctrl+C to stop") - app.start_scheduler() - except KeyboardInterrupt: - print("\nšŸ‘‹ Scheduler stopped") - - else: - print("ā“ Unknown command. Available commands:") - print(" setup - Initial setup and authentication") - print(" sync - Run manual sync") - print(" status - Show sync status") - print(" config - Show configuration info") - print(" readonly - Toggle read-only mode") - print(" schedule - Start scheduled sync") - else: - print("šŸƒ Weight Sync Application") - print("Syncs weight data from Fitbit API to Garmin Connect") - print("Run with 'python fitbit_sync.py '") - print("\nAvailable commands:") - print(" setup - Initial setup and authentication") - print(" sync - Run manual sync") - print(" status - Show sync status") - print(" config - Show configuration info") - print(" readonly - Toggle read-only mode (prevents Garmin uploads)") - print(" schedule - Start scheduled sync") - print("\nšŸ’” Tips:") - print(" - Use 'readonly' command to toggle between read-only and full sync mode") - print(" - Read-only mode will fetch from Fitbit but won't upload to Garmin") - print(" - First run 'setup' to configure API credentials") - print(" - Check 'status' to see sync history and current mode") - - # Show current mode - read_only_mode = app.config.get('sync.read_only_mode', False) - if read_only_mode: - print("\nšŸ“– Currently in READ-ONLY mode - will not upload to Garmin") - else: - print("\nšŸ”„ Currently in FULL SYNC mode - will upload to Garmin") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt index b4f0d0b..9fa1394 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ fitbit==0.3.1 garminconnect==0.2.30 garth==0.5.17 schedule==1.2.2 +python-consul