1418 lines
59 KiB
Python
1418 lines
59 KiB
Python
# Fitbit to Garmin Weight Sync Application
|
|
# Syncs weight data from Fitbit API to Garmin Connect
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import List, Dict, Optional, Tuple
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
import hashlib
|
|
import time
|
|
import os
|
|
import webbrowser
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
try:
|
|
import fitbit
|
|
FITBIT_LIBRARY = True
|
|
except ImportError:
|
|
FITBIT_LIBRARY = False
|
|
|
|
try:
|
|
from garminconnect import Garmin
|
|
GARMIN_LIBRARY = "garminconnect"
|
|
except ImportError:
|
|
try:
|
|
import garth
|
|
GARMIN_LIBRARY = "garth"
|
|
except ImportError:
|
|
GARMIN_LIBRARY = None
|
|
|
|
import schedule
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
handlers=[
|
|
logging.FileHandler('weight_sync.log'),
|
|
logging.StreamHandler()
|
|
]
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
@dataclass
|
|
class WeightRecord:
|
|
"""Represents a weight measurement"""
|
|
timestamp: datetime
|
|
weight_kg: float
|
|
source: str = "fitbit"
|
|
sync_id: Optional[str] = None
|
|
|
|
def __post_init__(self):
|
|
if self.sync_id is None:
|
|
# Create unique ID based on timestamp and weight
|
|
unique_string = f"{self.timestamp.isoformat()}_{self.weight_kg}"
|
|
self.sync_id = hashlib.md5(unique_string.encode()).hexdigest()
|
|
|
|
class ConfigManager:
|
|
"""Manages application configuration and credentials"""
|
|
|
|
def __init__(self, config_file: str = "config.json"):
|
|
self.config_file = Path(config_file)
|
|
self.config = self._load_config()
|
|
|
|
def _load_config(self) -> Dict:
|
|
"""Load configuration from file"""
|
|
if self.config_file.exists():
|
|
try:
|
|
with open(self.config_file, 'r') as f:
|
|
config = json.load(f)
|
|
# Ensure all required sections exist
|
|
default_config = self._create_default_config()
|
|
for section, defaults in default_config.items():
|
|
if section not in config:
|
|
config[section] = defaults
|
|
elif isinstance(defaults, dict):
|
|
for key, default_value in defaults.items():
|
|
if key not in config[section]:
|
|
config[section][key] = default_value
|
|
return config
|
|
except Exception as e:
|
|
logger.warning(f"Error loading config file: {e}")
|
|
return self._create_default_config()
|
|
return self._create_default_config()
|
|
|
|
|
|
def _create_default_config(self) -> Dict:
|
|
"""Create default configuration"""
|
|
config = {
|
|
"fitbit": {
|
|
"client_id": "",
|
|
"client_secret": "",
|
|
"access_token": "",
|
|
"refresh_token": "",
|
|
"token_file": "fitbit_token.json",
|
|
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
|
},
|
|
"garmin": {
|
|
"username": "",
|
|
"password": "",
|
|
"is_china": False, # Set to True if using Garmin China
|
|
"session_data_file": "garmin_session.json"
|
|
},
|
|
"sync": {
|
|
"sync_interval_minutes": 60,
|
|
"lookback_days": 7,
|
|
"max_retries": 3,
|
|
"read_only_mode": False # Set to True to prevent uploads to Garmin
|
|
},
|
|
"database": {
|
|
"path": "weight_sync.db"
|
|
}
|
|
}
|
|
# Don't automatically save here, let the caller decide
|
|
return config
|
|
|
|
def save_config(self, config: Dict = None):
|
|
"""Save configuration to file"""
|
|
if config:
|
|
self.config = config
|
|
with open(self.config_file, 'w') as f:
|
|
json.dump(self.config, f, indent=2)
|
|
|
|
def get(self, key: str, default=None):
|
|
"""Get configuration value using dot notation"""
|
|
keys = key.split('.')
|
|
value = self.config
|
|
for k in keys:
|
|
value = value.get(k, {})
|
|
return value if value != {} else default
|
|
|
|
def set_credentials(self, service: str, **kwargs):
|
|
"""Store credentials in config file"""
|
|
if service == "garmin":
|
|
# Ensure garmin section exists
|
|
if "garmin" not in self.config:
|
|
self.config["garmin"] = {}
|
|
self.config["garmin"]["username"] = kwargs.get("username", "")
|
|
self.config["garmin"]["password"] = kwargs.get("password", "")
|
|
elif service == "fitbit":
|
|
# Ensure fitbit section exists
|
|
if "fitbit" not in self.config:
|
|
self.config["fitbit"] = {
|
|
"client_id": "",
|
|
"client_secret": "",
|
|
"access_token": "",
|
|
"refresh_token": "",
|
|
"token_file": "fitbit_token.json",
|
|
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
|
}
|
|
|
|
for key, value in kwargs.items():
|
|
if key in self.config["fitbit"]:
|
|
self.config["fitbit"][key] = value
|
|
self.save_config()
|
|
|
|
def get_credentials(self, service: str, field: str) -> Optional[str]:
|
|
"""Retrieve stored credentials from config"""
|
|
if service == "garmin":
|
|
return self.config.get("garmin", {}).get(field)
|
|
elif service == "fitbit":
|
|
return self.config.get("fitbit", {}).get(field)
|
|
|
|
class DatabaseManager:
|
|
"""Manages SQLite database for sync state and records"""
|
|
|
|
def __init__(self, db_path: str):
|
|
self.db_path = db_path
|
|
self._init_database()
|
|
|
|
def _init_database(self):
|
|
"""Initialize database tables"""
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS weight_records (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sync_id TEXT UNIQUE NOT NULL,
|
|
timestamp TEXT NOT NULL,
|
|
weight_kg REAL NOT NULL,
|
|
source TEXT NOT NULL,
|
|
synced_to_garmin BOOLEAN DEFAULT FALSE,
|
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS sync_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
sync_type TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
message TEXT,
|
|
records_processed INTEGER DEFAULT 0,
|
|
timestamp TEXT DEFAULT CURRENT_TIMESTAMP
|
|
)
|
|
''')
|
|
|
|
# Create indexes separately
|
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_weight_timestamp ON weight_records(timestamp)')
|
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_weight_sync_id ON weight_records(sync_id)')
|
|
conn.execute('CREATE INDEX IF NOT EXISTS idx_sync_log_timestamp ON sync_log(timestamp)')
|
|
|
|
def save_weight_record(self, record: WeightRecord) -> bool:
|
|
"""Save weight record to database"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.execute('''
|
|
INSERT OR REPLACE INTO weight_records
|
|
(sync_id, timestamp, weight_kg, source, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
''', (
|
|
record.sync_id,
|
|
record.timestamp.isoformat(),
|
|
record.weight_kg,
|
|
record.source,
|
|
datetime.now().isoformat()
|
|
))
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error saving weight record: {e}")
|
|
return False
|
|
|
|
def get_unsynced_records(self) -> List[WeightRecord]:
|
|
"""Get records that haven't been synced to Garmin"""
|
|
records = []
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
cursor = conn.execute('''
|
|
SELECT sync_id, timestamp, weight_kg, source
|
|
FROM weight_records
|
|
WHERE synced_to_garmin = FALSE
|
|
ORDER BY timestamp DESC
|
|
''')
|
|
|
|
for row in cursor.fetchall():
|
|
record = WeightRecord(
|
|
sync_id=row[0],
|
|
timestamp=datetime.fromisoformat(row[1]),
|
|
weight_kg=row[2],
|
|
source=row[3]
|
|
)
|
|
records.append(record)
|
|
except Exception as e:
|
|
logger.error(f"Error getting unsynced records: {e}")
|
|
|
|
return records
|
|
|
|
def mark_synced(self, sync_id: str) -> bool:
|
|
"""Mark a record as synced to Garmin"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.execute('''
|
|
UPDATE weight_records
|
|
SET synced_to_garmin = TRUE, updated_at = ?
|
|
WHERE sync_id = ?
|
|
''', (datetime.now().isoformat(), sync_id))
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Error marking record as synced: {e}")
|
|
return False
|
|
|
|
def log_sync(self, sync_type: str, status: str, message: str = "", records_processed: int = 0):
|
|
"""Log sync operation"""
|
|
try:
|
|
with sqlite3.connect(self.db_path) as conn:
|
|
conn.execute('''
|
|
INSERT INTO sync_log (sync_type, status, message, records_processed)
|
|
VALUES (?, ?, ?, ?)
|
|
''', (sync_type, status, message, records_processed))
|
|
except Exception as e:
|
|
logger.error(f"Error logging sync: {e}")
|
|
|
|
class FitbitClient:
|
|
"""Client for Fitbit API using python-fitbit"""
|
|
|
|
def __init__(self, config: ConfigManager):
|
|
self.config = config
|
|
self.client = None
|
|
|
|
if not FITBIT_LIBRARY:
|
|
raise ImportError("python-fitbit library is not installed. Please install it with: pip install fitbit")
|
|
|
|
# Test if we can import the required modules
|
|
try:
|
|
import fitbit
|
|
from fitbit.api import FitbitOauth2Client
|
|
except ImportError as e:
|
|
logger.error(f"Failed to import required fitbit modules: {e}")
|
|
raise ImportError(f"Fitbit library import failed: {e}. Please reinstall with: pip install fitbit")
|
|
|
|
async def authenticate(self) -> bool:
|
|
"""Authenticate with Fitbit API"""
|
|
try:
|
|
client_id = self.config.get_credentials('fitbit', 'client_id')
|
|
client_secret = self.config.get_credentials('fitbit', 'client_secret')
|
|
|
|
if not client_id or not client_secret:
|
|
logger.info("No Fitbit credentials found. Please set them up.")
|
|
if not self._setup_credentials():
|
|
return False
|
|
# Reload credentials after setup
|
|
client_id = self.config.get_credentials('fitbit', 'client_id')
|
|
client_secret = self.config.get_credentials('fitbit', 'client_secret')
|
|
|
|
# Try to load existing tokens
|
|
access_token = self.config.get_credentials('fitbit', 'access_token')
|
|
refresh_token = self.config.get_credentials('fitbit', 'refresh_token')
|
|
|
|
if access_token and refresh_token:
|
|
# Try to use existing tokens
|
|
try:
|
|
self.client = fitbit.Fitbit(
|
|
client_id,
|
|
client_secret,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
refresh_cb=self._token_refresh_callback
|
|
)
|
|
|
|
# Test the connection
|
|
profile = self.client.user_profile_get()
|
|
logger.info(f"Successfully authenticated with existing tokens for user: {profile['user']['displayName']}")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Existing tokens invalid: {e}")
|
|
# Clear invalid tokens
|
|
self.config.set_credentials('fitbit', access_token="", refresh_token="")
|
|
# Fall through to OAuth flow
|
|
|
|
# Perform OAuth flow
|
|
return await self._oauth_flow(client_id, client_secret)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Fitbit authentication error: {e}")
|
|
import traceback
|
|
logger.error(f"Full error traceback: {traceback.format_exc()}")
|
|
return False
|
|
|
|
def _setup_credentials(self) -> bool:
|
|
"""Setup Fitbit credentials interactively"""
|
|
print("\n🔑 Fitbit API Credentials Setup")
|
|
print("=" * 40)
|
|
print("To get your Fitbit API credentials:")
|
|
print("1. Go to https://dev.fitbit.com/apps")
|
|
print("2. Create a new app or use an existing one")
|
|
print("3. Copy the Client ID and Client Secret")
|
|
print("4. Set OAuth 2.0 Application Type to 'Personal'")
|
|
print("5. Set Callback URL to: http://localhost:8080/fitbit-callback")
|
|
print()
|
|
|
|
client_id = input("Enter your Fitbit Client ID: ").strip()
|
|
if not client_id:
|
|
print("❌ Client ID cannot be empty")
|
|
return False
|
|
|
|
import getpass
|
|
client_secret = getpass.getpass("Enter your Fitbit Client Secret: ").strip()
|
|
if not client_secret:
|
|
print("❌ Client Secret cannot be empty")
|
|
return False
|
|
|
|
# Store credentials
|
|
self.config.set_credentials('fitbit', client_id=client_id, client_secret=client_secret)
|
|
|
|
print("✅ Credentials saved")
|
|
return True
|
|
|
|
async def _oauth_flow(self, client_id: str, client_secret: str) -> bool:
|
|
"""Perform OAuth 2.0 authorization flow"""
|
|
try:
|
|
redirect_uri = self.config.get('fitbit.redirect_uri')
|
|
|
|
# Create Fitbit client for OAuth
|
|
from fitbit.api import FitbitOauth2Client
|
|
|
|
auth_client = FitbitOauth2Client(
|
|
client_id,
|
|
client_secret,
|
|
redirect_uri=redirect_uri
|
|
)
|
|
|
|
# Get authorization URL
|
|
auth_url, _ = auth_client.authorize_token_url()
|
|
|
|
print("\n🔐 Fitbit OAuth Authorization")
|
|
print("=" * 40)
|
|
print("Opening your browser for Fitbit authorization...")
|
|
print(f"If it doesn't open automatically, visit: {auth_url}")
|
|
print("\nAfter authorizing the app, you'll be redirected to a page that may show an error.")
|
|
print("That's normal! Just copy the FULL URL from your browser's address bar.")
|
|
print()
|
|
|
|
# Open browser
|
|
try:
|
|
webbrowser.open(auth_url)
|
|
except Exception as e:
|
|
logger.warning(f"Could not open browser: {e}")
|
|
|
|
# Get the callback URL from user
|
|
callback_url = input("After authorization, paste the full callback URL here: ").strip()
|
|
|
|
if not callback_url:
|
|
print("❌ Callback URL cannot be empty")
|
|
return False
|
|
|
|
# Extract authorization code from callback URL
|
|
parsed_url = urlparse(callback_url)
|
|
query_params = parse_qs(parsed_url.query)
|
|
|
|
if 'code' not in query_params:
|
|
print("❌ No authorization code found in callback URL")
|
|
print(f"URL received: {callback_url}")
|
|
print("Make sure you copied the complete URL after authorization")
|
|
return False
|
|
|
|
auth_code = query_params['code'][0]
|
|
|
|
# Exchange code for tokens
|
|
token = auth_client.fetch_access_token(auth_code)
|
|
|
|
# Save tokens
|
|
self.config.set_credentials(
|
|
'fitbit',
|
|
access_token=token['access_token'],
|
|
refresh_token=token['refresh_token']
|
|
)
|
|
|
|
# Create authenticated client
|
|
self.client = fitbit.Fitbit(
|
|
client_id,
|
|
client_secret,
|
|
access_token=token['access_token'],
|
|
refresh_token=token['refresh_token'],
|
|
refresh_cb=self._token_refresh_callback
|
|
)
|
|
|
|
# Test the connection
|
|
profile = self.client.user_profile_get()
|
|
print(f"✅ Successfully authenticated for user: {profile['user']['displayName']}")
|
|
logger.info(f"Successfully authenticated for user: {profile['user']['displayName']}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"OAuth flow failed: {e}")
|
|
import traceback
|
|
logger.error(f"Full error traceback: {traceback.format_exc()}")
|
|
print(f"❌ OAuth authentication failed: {e}")
|
|
return False
|
|
|
|
def _token_refresh_callback(self, token):
|
|
"""Callback for when tokens are refreshed"""
|
|
logger.info("Fitbit tokens refreshed")
|
|
self.config.set_credentials(
|
|
'fitbit',
|
|
access_token=token['access_token'],
|
|
refresh_token=token['refresh_token']
|
|
)
|
|
|
|
async def get_weight_data(self, start_date: datetime, end_date: datetime) -> List[WeightRecord]:
|
|
"""Fetch weight data from Fitbit API"""
|
|
if not self.client:
|
|
logger.error("Fitbit client not authenticated")
|
|
return []
|
|
|
|
logger.info(f"Fetching weight data from Fitbit API from {start_date.date()} to {end_date.date()}")
|
|
|
|
records = []
|
|
|
|
try:
|
|
# Fitbit API expects dates in YYYY-mm-dd format
|
|
start_date_str = start_date.strftime("%Y-%m-%d")
|
|
end_date_str = end_date.strftime("%Y-%m-%d")
|
|
|
|
# Get weight data from Fitbit
|
|
weight_data = self.client.get_bodyweight(
|
|
base_date=start_date_str,
|
|
end_date=end_date_str
|
|
)
|
|
|
|
logger.info(f"Raw Fitbit API response keys: {list(weight_data.keys()) if weight_data else 'None'}")
|
|
|
|
# Parse weight data - handle both possible response formats
|
|
weight_entries = None
|
|
if weight_data:
|
|
# Try the format from your actual API response
|
|
if 'weight' in weight_data:
|
|
weight_entries = weight_data['weight']
|
|
logger.info(f"Found weight data in 'weight' key")
|
|
# Try the format the original code expected
|
|
elif 'body-weight' in weight_data:
|
|
weight_entries = weight_data['body-weight']
|
|
logger.info(f"Found weight data in 'body-weight' key")
|
|
else:
|
|
logger.warning(f"Unexpected API response format. Keys: {list(weight_data.keys())}")
|
|
|
|
if weight_entries:
|
|
logger.info(f"Processing {len(weight_entries)} weight entries")
|
|
|
|
for weight_entry in weight_entries:
|
|
try:
|
|
# Parse date and time
|
|
date_str = weight_entry['date']
|
|
time_str = weight_entry.get('time', '00:00:00')
|
|
|
|
# Combine date and time
|
|
datetime_str = f"{date_str} {time_str}"
|
|
timestamp = datetime.strptime(datetime_str, "%Y-%m-%d %H:%M:%S")
|
|
timestamp = timestamp.replace(tzinfo=timezone.utc)
|
|
|
|
# Get weight - the API returns weight in pounds, need to convert to kg
|
|
weight_lbs = float(weight_entry['weight'])
|
|
weight_kg = weight_lbs * 0.453592 # Convert pounds to kg
|
|
|
|
record = WeightRecord(
|
|
timestamp=timestamp,
|
|
weight_kg=weight_kg,
|
|
source="fitbit"
|
|
)
|
|
records.append(record)
|
|
|
|
logger.info(f"Found weight record: {weight_lbs}lbs ({weight_kg:.1f}kg) at {timestamp}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse weight entry {weight_entry}: {e}")
|
|
continue
|
|
else:
|
|
logger.info("No weight entries found in API response")
|
|
|
|
logger.info(f"Retrieved {len(records)} weight records from Fitbit")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching Fitbit weight data: {e}")
|
|
import traceback
|
|
logger.error(f"Full traceback: {traceback.format_exc()}")
|
|
|
|
return records
|
|
|
|
class GarminClient:
|
|
"""Client for Garmin Connect using garminconnect or garth library"""
|
|
|
|
def __init__(self, config: ConfigManager):
|
|
self.config = config
|
|
self.username = None
|
|
self.password = None
|
|
self.is_china = config.get('garmin.is_china', False)
|
|
self.session_file = config.get('garmin.session_data_file', 'garmin_session.json')
|
|
self.garmin_client = None
|
|
self.read_only_mode = config.get('sync.read_only_mode', False)
|
|
|
|
if not GARMIN_LIBRARY:
|
|
raise ImportError("Neither 'garminconnect' nor 'garth' library is installed. Please install one of them.")
|
|
|
|
async def authenticate(self) -> bool:
|
|
"""Authenticate with Garmin Connect"""
|
|
if self.read_only_mode:
|
|
logger.info("Running in read-only mode - skipping Garmin authentication")
|
|
return True
|
|
|
|
try:
|
|
# Get credentials from config
|
|
self.username = self.config.get_credentials('garmin', 'username')
|
|
self.password = self.config.get_credentials('garmin', 'password')
|
|
|
|
if not self.username or not self.password:
|
|
logger.info("No stored Garmin credentials found. Please set them up.")
|
|
return self._setup_credentials()
|
|
|
|
if GARMIN_LIBRARY == "garminconnect":
|
|
return await self._authenticate_garminconnect()
|
|
elif GARMIN_LIBRARY == "garth":
|
|
return await self._authenticate_garth()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Garmin authentication error: {e}")
|
|
return False
|
|
|
|
def _setup_credentials(self) -> bool:
|
|
"""Setup Garmin credentials interactively"""
|
|
print("\n🔑 Garmin Connect Credentials Setup")
|
|
print("=" * 40)
|
|
|
|
username = input("Enter your Garmin Connect username/email: ").strip()
|
|
if not username:
|
|
print("❌ Username cannot be empty")
|
|
return False
|
|
|
|
import getpass
|
|
password = getpass.getpass("Enter your Garmin Connect password: ").strip()
|
|
if not password:
|
|
print("❌ Password cannot be empty")
|
|
return False
|
|
|
|
# Store credentials in config
|
|
self.config.set_credentials('garmin', username=username, password=password)
|
|
|
|
self.username = username
|
|
self.password = password
|
|
|
|
print("✅ Credentials saved securely")
|
|
return True
|
|
|
|
async def _authenticate_garminconnect(self) -> bool:
|
|
"""Authenticate using garminconnect library"""
|
|
try:
|
|
logger.info("Authenticating with Garmin Connect using garminconnect library...")
|
|
|
|
# Create Garmin client (garminconnect doesn't support is_china parameter)
|
|
self.garmin_client = Garmin(
|
|
email=self.username,
|
|
password=self.password
|
|
)
|
|
|
|
# Try to load existing session
|
|
if os.path.exists(self.session_file):
|
|
try:
|
|
with open(self.session_file, 'r') as f:
|
|
session_data = json.load(f)
|
|
self.garmin_client.login()
|
|
logger.info("Loaded existing Garmin session")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load existing session: {e}")
|
|
|
|
# Perform fresh login
|
|
self.garmin_client.login()
|
|
|
|
# Save session data for future use (if available)
|
|
try:
|
|
# Different versions of garminconnect may have different session handling
|
|
if hasattr(self.garmin_client, 'session') and self.garmin_client.session:
|
|
session_data = {
|
|
"session_data": self.garmin_client.session.cookies.get_dict(),
|
|
"timestamp": datetime.now().isoformat()
|
|
}
|
|
with open(self.session_file, 'w') as f:
|
|
json.dump(session_data, f)
|
|
logger.info("Saved Garmin session data")
|
|
else:
|
|
logger.info("Session data not available for saving")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to save session data: {e}")
|
|
|
|
# Test the connection
|
|
user_profile = self.garmin_client.get_user_profile()
|
|
logger.info(f"Successfully authenticated as {user_profile.get('displayName', 'Unknown')}")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"garminconnect authentication failed: {e}")
|
|
return False
|
|
|
|
async def _authenticate_garth(self) -> bool:
|
|
"""Authenticate using garth library"""
|
|
try:
|
|
logger.info("Authenticating with Garmin Connect using garth library...")
|
|
|
|
# Configure garth
|
|
garth.configure(domain="garmin.com" if not self.is_china else "garmin.cn")
|
|
|
|
# Try to load existing session
|
|
if os.path.exists(self.session_file):
|
|
try:
|
|
garth.resume(self.session_file)
|
|
garth.client.username # Test if session is valid
|
|
logger.info("Resumed existing Garmin session")
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(f"Failed to resume existing session: {e}")
|
|
|
|
# Perform fresh login
|
|
garth.login(self.username, self.password)
|
|
|
|
# Save session
|
|
garth.save(self.session_file)
|
|
logger.info("Successfully authenticated and saved session")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"garth authentication failed: {e}")
|
|
return False
|
|
|
|
async def upload_weight_data(self, records: List[WeightRecord]) -> Tuple[int, int]:
|
|
"""Upload weight records to Garmin"""
|
|
if self.read_only_mode:
|
|
logger.info(f"Read-only mode: Would upload {len(records)} weight records to Garmin")
|
|
# Simulate successful upload in read-only mode
|
|
for record in records:
|
|
logger.info(f"Read-only mode: Would upload {record.weight_kg}kg at {record.timestamp}")
|
|
return len(records), 0 # All "successful", none failed
|
|
|
|
if not self.garmin_client and GARMIN_LIBRARY == "garminconnect":
|
|
logger.error("Not authenticated with Garmin (garminconnect)")
|
|
return 0, len(records)
|
|
|
|
if GARMIN_LIBRARY == "garth" and not os.path.exists(self.session_file):
|
|
logger.error("Not authenticated with Garmin (garth)")
|
|
return 0, len(records)
|
|
|
|
success_count = 0
|
|
total_count = len(records)
|
|
|
|
for record in records:
|
|
try:
|
|
if GARMIN_LIBRARY == "garminconnect":
|
|
success = await self._upload_weight_garminconnect(record)
|
|
elif GARMIN_LIBRARY == "garth":
|
|
success = await self._upload_weight_garth(record)
|
|
else:
|
|
success = False
|
|
|
|
if success:
|
|
success_count += 1
|
|
logger.info(f"Successfully uploaded weight: {record.weight_kg}kg at {record.timestamp}")
|
|
else:
|
|
logger.error(f"Failed to upload weight record: {record.weight_kg}kg at {record.timestamp}")
|
|
|
|
# Rate limiting - wait between requests
|
|
await asyncio.sleep(1)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error uploading weight record: {e}")
|
|
|
|
return success_count, total_count - success_count
|
|
|
|
def check_garmin_weights(self, days: int = 30):
|
|
"""Check recent Garmin weights for anomalies"""
|
|
try:
|
|
if self.read_only_mode:
|
|
logger.info("Read-only mode: Cannot check Garmin weights")
|
|
return
|
|
|
|
recent_weights = self.get_recent_weights(days)
|
|
|
|
if not recent_weights:
|
|
print("No recent weights found in Garmin")
|
|
return
|
|
|
|
print(f"\n⚖️ Recent Garmin Weights (last {days} days):")
|
|
print("=" * 50)
|
|
|
|
anomalies = []
|
|
normal_weights = []
|
|
|
|
for weight_entry in recent_weights:
|
|
try:
|
|
date = weight_entry.get('calendarDate', 'Unknown')
|
|
weight_raw = weight_entry.get('weight', 0)
|
|
|
|
# Garmin stores weight in grams, convert to kg
|
|
weight_kg = weight_raw / 1000 if weight_raw else 0
|
|
|
|
# Check for anomalies (weights outside normal human range)
|
|
if weight_kg > 1000 or weight_kg < 20: # Clearly wrong values
|
|
anomalies.append((date, weight_kg, weight_raw))
|
|
status = "❌ ANOMALY"
|
|
elif weight_kg > 300 or weight_kg < 30: # Suspicious values
|
|
anomalies.append((date, weight_kg, weight_raw))
|
|
status = "⚠️ SUSPICIOUS"
|
|
else:
|
|
normal_weights.append((date, weight_kg))
|
|
status = "✅ OK"
|
|
|
|
print(f"📅 {date}: {weight_kg:.1f}kg (raw: {weight_raw}) {status}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error parsing weight entry: {e}")
|
|
|
|
if anomalies:
|
|
print(f"\n🚨 Found {len(anomalies)} anomalous weight entries!")
|
|
print("These may need to be manually deleted from Garmin Connect.")
|
|
print("Anomalous entries:")
|
|
for date, weight_kg, weight_raw in anomalies:
|
|
print(f" - {date}: {weight_kg:.1f}kg (raw value: {weight_raw})")
|
|
|
|
if normal_weights:
|
|
print(f"\n✅ {len(normal_weights)} normal weight entries found")
|
|
avg_weight = sum(w[1] for w in normal_weights) / len(normal_weights)
|
|
print(f"Average weight: {avg_weight:.1f}kg")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking Garmin weights: {e}")
|
|
print(f"❌ Error checking Garmin weights: {e}")
|
|
|
|
|
|
async def _upload_weight_garminconnect(self, record: WeightRecord) -> bool:
|
|
"""Upload weight using garminconnect library"""
|
|
try:
|
|
# Convert timestamp to various formats we might need
|
|
date_str = record.timestamp.strftime("%Y-%m-%d")
|
|
timestamp_ms = int(record.timestamp.timestamp() * 1000)
|
|
|
|
# Weight should be in kg for most methods
|
|
weight_kg = record.weight_kg
|
|
|
|
logger.info(f"Uploading weight: {weight_kg}kg on {date_str}")
|
|
|
|
# Try methods in order of most likely to work correctly
|
|
upload_attempts = [
|
|
# Method 1: add_weigh_in_with_timestamps (if available)
|
|
{
|
|
'method': 'add_weigh_in_with_timestamps',
|
|
'args': [timestamp_ms, weight_kg],
|
|
'kwargs': {},
|
|
'description': 'timestamp + weight in kg'
|
|
},
|
|
# Method 2: add_weigh_in with date string and weight in kg
|
|
{
|
|
'method': 'add_weigh_in',
|
|
'args': [date_str, weight_kg],
|
|
'kwargs': {},
|
|
'description': 'date string + weight in kg'
|
|
},
|
|
# Method 3: add_weigh_in with keyword arguments
|
|
{
|
|
'method': 'add_weigh_in',
|
|
'args': [],
|
|
'kwargs': {'date': date_str, 'weight': weight_kg},
|
|
'description': 'kwargs: date + weight in kg'
|
|
},
|
|
# Method 4: add_body_composition with just date and weight
|
|
{
|
|
'method': 'add_body_composition',
|
|
'args': [date_str],
|
|
'kwargs': {'weight': weight_kg},
|
|
'description': 'date + weight in kg as kwarg'
|
|
},
|
|
# Method 5: add_body_composition with date and weight as positional args
|
|
{
|
|
'method': 'add_body_composition',
|
|
'args': [date_str, weight_kg],
|
|
'kwargs': {},
|
|
'description': 'date + weight in kg as positional args'
|
|
},
|
|
# Method 6: Try without any timestamp, just weight
|
|
{
|
|
'method': 'add_body_composition',
|
|
'args': [],
|
|
'kwargs': {'weight': weight_kg},
|
|
'description': 'weight only in kg'
|
|
}
|
|
]
|
|
|
|
for i, attempt in enumerate(upload_attempts, 1):
|
|
method_name = attempt['method']
|
|
args = attempt['args']
|
|
kwargs = attempt['kwargs']
|
|
description = attempt['description']
|
|
|
|
if hasattr(self.garmin_client, method_name):
|
|
try:
|
|
logger.info(f"Trying method {i}: {method_name} ({description})")
|
|
logger.info(f" Args: {args}")
|
|
logger.info(f" Kwargs: {kwargs}")
|
|
|
|
method = getattr(self.garmin_client, method_name)
|
|
|
|
# Call method with both positional and keyword arguments
|
|
if args and kwargs:
|
|
result = method(*args, **kwargs)
|
|
elif args:
|
|
result = method(*args)
|
|
elif kwargs:
|
|
result = method(**kwargs)
|
|
else:
|
|
result = method()
|
|
|
|
logger.info(f"✅ Method {method_name} succeeded. Result: {result}")
|
|
|
|
# Verify the upload by checking recent weights
|
|
await asyncio.sleep(2) # Give Garmin time to process
|
|
try:
|
|
recent_weights = self.get_recent_weights(1)
|
|
if recent_weights:
|
|
latest_weight = recent_weights[0]
|
|
garmin_weight_kg = latest_weight.get('weight', 0) / 1000 if latest_weight.get('weight') else 0
|
|
logger.info(f"Verification: Latest Garmin weight is {garmin_weight_kg}kg")
|
|
|
|
# Check if the weight is reasonable (within 10% of what we uploaded)
|
|
if abs(garmin_weight_kg - weight_kg) / weight_kg < 0.1:
|
|
logger.info(f"✅ Weight verification passed: {garmin_weight_kg}kg ≈ {weight_kg}kg")
|
|
return True
|
|
else:
|
|
logger.warning(f"⚠️ Weight verification failed: uploaded {weight_kg}kg but Garmin shows {garmin_weight_kg}kg")
|
|
# Continue to try other methods
|
|
except Exception as e:
|
|
logger.warning(f"Could not verify upload: {e}")
|
|
# Assume success if we can't verify
|
|
return True
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Method {method_name} failed: {e}")
|
|
# Log the full error for the first few attempts
|
|
if i <= 3:
|
|
import traceback
|
|
logger.warning(f"Full error for {method_name}: {traceback.format_exc()}")
|
|
continue
|
|
else:
|
|
logger.debug(f"Method {method_name} not available")
|
|
|
|
logger.error("All upload methods failed")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"garminconnect upload error: {e}")
|
|
import traceback
|
|
logger.error(f"Full traceback: {traceback.format_exc()}")
|
|
return False
|
|
|
|
async def _upload_weight_garth(self, record: WeightRecord) -> bool:
|
|
"""Upload weight using garth library"""
|
|
try:
|
|
# Garth expects weight in kg and timestamp in specific format
|
|
timestamp_ms = int(record.timestamp.timestamp() * 1000)
|
|
|
|
# Use garth to upload weight
|
|
response = garth.post(
|
|
"biometric-service",
|
|
"/biometric-service/weight",
|
|
json={
|
|
"timestampGMT": timestamp_ms,
|
|
"unitKey": "kg",
|
|
"value": record.weight_kg
|
|
}
|
|
)
|
|
|
|
return response.status_code in [200, 201, 204]
|
|
|
|
except Exception as e:
|
|
logger.error(f"garth upload error: {e}")
|
|
return False
|
|
|
|
def get_recent_weights(self, days: int = 7) -> List[Dict]:
|
|
"""Get recent weight data from Garmin (for verification)"""
|
|
if self.read_only_mode:
|
|
logger.info("Read-only mode: Cannot fetch Garmin weights")
|
|
return []
|
|
|
|
try:
|
|
if GARMIN_LIBRARY == "garminconnect" and self.garmin_client:
|
|
end_date = datetime.now().date()
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
weights = self.garmin_client.get_body_composition(
|
|
start_date.isoformat(),
|
|
end_date.isoformat()
|
|
)
|
|
return weights or []
|
|
|
|
elif GARMIN_LIBRARY == "garth":
|
|
# Implementation for garth if needed
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting recent weights: {e}")
|
|
return []
|
|
|
|
class WeightSyncApp:
|
|
"""Main application class"""
|
|
|
|
def __init__(self, config_file: str = "config.json"):
|
|
self.config = ConfigManager(config_file)
|
|
self.db = DatabaseManager(self.config.get('database.path'))
|
|
self.fitbit = FitbitClient(self.config)
|
|
self.garmin = GarminClient(self.config)
|
|
|
|
async def setup(self):
|
|
"""Setup and authenticate with services"""
|
|
logger.info("Setting up Weight Sync Application...")
|
|
|
|
# Authenticate with Fitbit
|
|
if not await self.fitbit.authenticate():
|
|
logger.error("Failed to authenticate with Fitbit")
|
|
return False
|
|
|
|
# Authenticate with Garmin (unless in read-only mode)
|
|
if not await self.garmin.authenticate():
|
|
if not self.config.get('sync.read_only_mode', False):
|
|
logger.error("Failed to authenticate with Garmin")
|
|
return False
|
|
|
|
logger.info("Setup completed successfully")
|
|
return True
|
|
async def force_full_sync(self, days: int = 365):
|
|
"""Perform full sync with custom lookback period"""
|
|
try:
|
|
logger.info(f"Starting FULL weight data sync (looking back {days} days)...")
|
|
|
|
read_only_mode = self.config.get('sync.read_only_mode', False)
|
|
if read_only_mode:
|
|
logger.info("Running in read-only mode - will not upload to Garmin")
|
|
|
|
# Get extended date range for sync
|
|
end_date = datetime.now(timezone.utc)
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
logger.info(f"Fetching Fitbit data from {start_date.date()} to {end_date.date()}")
|
|
|
|
# Fetch data from Fitbit
|
|
fitbit_records = await self.fitbit.get_weight_data(start_date, end_date)
|
|
|
|
if not fitbit_records:
|
|
logger.warning("No weight records found in Fitbit for the specified period")
|
|
print("❌ No weight records found in Fitbit for the specified period")
|
|
return False
|
|
|
|
logger.info(f"Found {len(fitbit_records)} weight records from Fitbit")
|
|
print(f"📊 Found {len(fitbit_records)} weight records from Fitbit")
|
|
|
|
# Save new records to database
|
|
new_records = 0
|
|
updated_records = 0
|
|
for record in fitbit_records:
|
|
if self.db.save_weight_record(record):
|
|
new_records += 1
|
|
else:
|
|
updated_records += 1
|
|
|
|
logger.info(f"Processed {new_records} new weight records, {updated_records} updated records")
|
|
print(f"💾 Processed {new_records} new records, {updated_records} updated records")
|
|
|
|
# Get unsynced records
|
|
unsynced_records = self.db.get_unsynced_records()
|
|
|
|
if not unsynced_records:
|
|
logger.info("No unsynced records found")
|
|
print("✅ All records are already synced")
|
|
return True
|
|
|
|
print(f"🔄 Found {len(unsynced_records)} records to sync to Garmin")
|
|
|
|
# Upload to Garmin (or simulate in read-only mode)
|
|
success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records)
|
|
|
|
# Mark successful uploads as synced
|
|
synced_count = 0
|
|
for record in unsynced_records[:success_count]:
|
|
if self.db.mark_synced(record.sync_id):
|
|
synced_count += 1
|
|
|
|
# Log results
|
|
mode_prefix = "(Read-only) " if read_only_mode else ""
|
|
message = f"{mode_prefix}Full sync: {synced_count} records synced, {failed_count} failed"
|
|
status = "success" if failed_count == 0 else "partial"
|
|
self.db.log_sync("full_sync", status, message, synced_count)
|
|
|
|
logger.info(f"Full sync completed: {message}")
|
|
print(f"✅ Full sync completed: {synced_count} synced, {failed_count} failed")
|
|
return True
|
|
|
|
except Exception as e:
|
|
error_msg = f"Full sync failed: {e}"
|
|
logger.error(error_msg)
|
|
self.db.log_sync("full_sync", "error", error_msg, 0)
|
|
print(f"❌ Full sync failed: {e}")
|
|
return False
|
|
|
|
async def debug_fitbit_data(self, days: int = 30):
|
|
"""Debug Fitbit data retrieval"""
|
|
try:
|
|
logger.info("Setting up Fitbit client for debugging...")
|
|
if not await self.fitbit.authenticate():
|
|
print("❌ Failed to authenticate with Fitbit")
|
|
return
|
|
|
|
end_date = datetime.now(timezone.utc)
|
|
start_date = end_date - timedelta(days=days)
|
|
|
|
print(f"🔍 Checking Fitbit data from {start_date.date()} to {end_date.date()}")
|
|
|
|
# Raw API call to see what we get
|
|
if self.fitbit.client:
|
|
try:
|
|
start_date_str = start_date.strftime("%Y-%m-%d")
|
|
end_date_str = end_date.strftime("%Y-%m-%d")
|
|
|
|
print(f"📡 Making API call: get_bodyweight({start_date_str}, {end_date_str})")
|
|
|
|
weight_data = self.fitbit.client.get_bodyweight(
|
|
base_date=start_date_str,
|
|
end_date=end_date_str
|
|
)
|
|
|
|
print(f"📄 Raw Fitbit API response:")
|
|
print(json.dumps(weight_data, indent=2))
|
|
|
|
# Also try individual day calls
|
|
print(f"\n📅 Trying individual day calls for last 7 days:")
|
|
for i in range(7):
|
|
check_date = end_date - timedelta(days=i)
|
|
date_str = check_date.strftime("%Y-%m-%d")
|
|
try:
|
|
daily_data = self.fitbit.client.get_bodyweight(base_date=date_str)
|
|
if daily_data and daily_data.get('body-weight'):
|
|
print(f" {date_str}: {daily_data['body-weight']}")
|
|
else:
|
|
print(f" {date_str}: No data")
|
|
except Exception as e:
|
|
print(f" {date_str}: Error - {e}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ API call failed: {e}")
|
|
import traceback
|
|
print(f"Full traceback: {traceback.format_exc()}")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Debug failed: {e}")
|
|
|
|
|
|
def reset_sync_status(self):
|
|
"""Reset all records to unsynced status"""
|
|
try:
|
|
with sqlite3.connect(self.db.db_path) as conn:
|
|
result = conn.execute('''
|
|
UPDATE weight_records
|
|
SET synced_to_garmin = FALSE, updated_at = ?
|
|
''', (datetime.now().isoformat(),))
|
|
|
|
affected_rows = result.rowcount
|
|
logger.info(f"Reset sync status for {affected_rows} records")
|
|
print(f"🔄 Reset sync status for {affected_rows} records")
|
|
print(" All records will be synced again on next sync")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error resetting sync status: {e}")
|
|
print(f"❌ Error resetting sync status: {e}")
|
|
return False
|
|
|
|
|
|
async def sync_weight_data(self) -> bool:
|
|
"""Perform weight data synchronization"""
|
|
try:
|
|
logger.info("Starting weight data sync...")
|
|
|
|
read_only_mode = self.config.get('sync.read_only_mode', False)
|
|
if read_only_mode:
|
|
logger.info("Running in read-only mode - will not upload to Garmin")
|
|
|
|
# Get date range for sync
|
|
lookback_days = self.config.get('sync.lookback_days', 7)
|
|
end_date = datetime.now(timezone.utc)
|
|
start_date = end_date - timedelta(days=lookback_days)
|
|
|
|
# Fetch data from Fitbit
|
|
fitbit_records = await self.fitbit.get_weight_data(start_date, end_date)
|
|
|
|
# Save new records to database
|
|
new_records = 0
|
|
for record in fitbit_records:
|
|
if self.db.save_weight_record(record):
|
|
new_records += 1
|
|
|
|
logger.info(f"Processed {new_records} new weight records from Fitbit")
|
|
|
|
# Get unsynced records
|
|
unsynced_records = self.db.get_unsynced_records()
|
|
|
|
if not unsynced_records:
|
|
logger.info("No unsynced records found")
|
|
self.db.log_sync("weight_sync", "success", "No records to sync", 0)
|
|
return True
|
|
|
|
# Upload to Garmin (or simulate in read-only mode)
|
|
success_count, failed_count = await self.garmin.upload_weight_data(unsynced_records)
|
|
|
|
# Mark successful uploads as synced (even in read-only mode for simulation)
|
|
synced_count = 0
|
|
for record in unsynced_records[:success_count]:
|
|
if self.db.mark_synced(record.sync_id):
|
|
synced_count += 1
|
|
|
|
# Log results
|
|
mode_prefix = "(Read-only) " if read_only_mode else ""
|
|
message = f"{mode_prefix}Synced {synced_count} records, {failed_count} failed"
|
|
status = "success" if failed_count == 0 else "partial"
|
|
self.db.log_sync("weight_sync", status, message, synced_count)
|
|
|
|
logger.info(f"Sync completed: {message}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
error_msg = f"Sync failed: {e}"
|
|
logger.error(error_msg)
|
|
self.db.log_sync("weight_sync", "error", error_msg, 0)
|
|
return False
|
|
|
|
def start_scheduler(self):
|
|
"""Start the sync scheduler"""
|
|
sync_interval = self.config.get('sync.sync_interval_minutes', 60)
|
|
|
|
logger.info(f"Starting scheduler with {sync_interval} minute interval")
|
|
|
|
# Schedule sync
|
|
schedule.every(sync_interval).minutes.do(
|
|
lambda: asyncio.create_task(self.sync_weight_data())
|
|
)
|
|
|
|
# Run initial sync
|
|
asyncio.create_task(self.sync_weight_data())
|
|
|
|
# Keep scheduler running
|
|
while True:
|
|
schedule.run_pending()
|
|
time.sleep(60) # Check every minute
|
|
|
|
async def manual_sync(self):
|
|
"""Perform manual sync"""
|
|
success = await self.sync_weight_data()
|
|
if success:
|
|
print("✅ Manual sync completed successfully")
|
|
else:
|
|
print("❌ Manual sync failed - check logs for details")
|
|
|
|
def show_status(self):
|
|
"""Show application status"""
|
|
try:
|
|
read_only_mode = self.config.get('sync.read_only_mode', False)
|
|
|
|
with sqlite3.connect(self.db.db_path) as conn:
|
|
# Get record counts
|
|
total_records = conn.execute("SELECT COUNT(*) FROM weight_records").fetchone()[0]
|
|
synced_records = conn.execute("SELECT COUNT(*) FROM weight_records WHERE synced_to_garmin = TRUE").fetchone()[0]
|
|
unsynced_records = total_records - synced_records
|
|
|
|
# Get recent sync logs
|
|
recent_syncs = conn.execute('''
|
|
SELECT timestamp, status, message, records_processed
|
|
FROM sync_log
|
|
ORDER BY timestamp DESC
|
|
LIMIT 5
|
|
''').fetchall()
|
|
|
|
print("\n📊 Weight Sync Status")
|
|
print("=" * 50)
|
|
print(f"Mode: {'Read-only (No Garmin uploads)' if read_only_mode else 'Full sync mode'}")
|
|
print(f"Fitbit Library: {'Available' if FITBIT_LIBRARY else 'Not Available'}")
|
|
print(f"Garmin Library: {GARMIN_LIBRARY or 'Not Available'}")
|
|
print(f"Total weight records: {total_records}")
|
|
print(f"Synced to Garmin: {synced_records}")
|
|
print(f"Pending sync: {unsynced_records}")
|
|
|
|
print(f"\n📝 Recent Sync History:")
|
|
if recent_syncs:
|
|
for sync in recent_syncs:
|
|
status_emoji = "✅" if sync[1] == "success" else "⚠️" if sync[1] == "partial" else "❌"
|
|
print(f" {status_emoji} {sync[0]} - {sync[1]} - {sync[2]} ({sync[3]} records)")
|
|
else:
|
|
print(" No sync history found")
|
|
|
|
# Show recent Garmin weights if available and not in read-only mode
|
|
if not read_only_mode:
|
|
try:
|
|
recent_weights = self.garmin.get_recent_weights(7)
|
|
if recent_weights:
|
|
print(f"\n⚖️ Recent Garmin Weights:")
|
|
for weight in recent_weights[:5]: # Show last 5
|
|
date = weight.get('calendarDate', 'Unknown')
|
|
weight_kg = weight.get('weight', 0) / 1000 if weight.get('weight') else 'Unknown'
|
|
print(f" 📅 {date}: {weight_kg}kg")
|
|
except Exception as e:
|
|
logger.debug(f"Could not fetch recent Garmin weights: {e}")
|
|
|
|
# Show recent database records
|
|
recent_records = conn.execute('''
|
|
SELECT timestamp, weight_kg, source, synced_to_garmin
|
|
FROM weight_records
|
|
ORDER BY timestamp DESC
|
|
LIMIT 5
|
|
''').fetchall()
|
|
|
|
if recent_records:
|
|
print(f"\n📈 Recent Weight Records:")
|
|
for record in recent_records:
|
|
sync_status = "✅" if record[3] else "⏳"
|
|
timestamp = datetime.fromisoformat(record[0])
|
|
print(f" {sync_status} {timestamp.strftime('%Y-%m-%d %H:%M')}: {record[1]}kg ({record[2]})")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error getting status: {e}")
|
|
|
|
def toggle_read_only_mode(self):
|
|
"""Toggle read-only mode"""
|
|
current_mode = self.config.get('sync.read_only_mode', False)
|
|
new_mode = not current_mode
|
|
|
|
self.config.config['sync']['read_only_mode'] = new_mode
|
|
self.config.save_config()
|
|
|
|
mode_text = "enabled" if new_mode else "disabled"
|
|
print(f"✅ Read-only mode {mode_text}")
|
|
print(f" {'Will NOT upload to Garmin' if new_mode else 'Will upload to Garmin'}")
|
|
|
|
async def main():
|
|
"""Main application entry point"""
|
|
import sys
|
|
|
|
app = WeightSyncApp()
|
|
|
|
if len(sys.argv) > 1:
|
|
command = sys.argv[1].lower()
|
|
|
|
if command == "setup":
|
|
success = await app.setup()
|
|
if success:
|
|
print("✅ Setup completed successfully")
|
|
else:
|
|
print("❌ Setup failed")
|
|
|
|
elif command == "sync":
|
|
await app.setup()
|
|
await app.manual_sync()
|
|
|
|
elif command == "status":
|
|
app.show_status()
|
|
|
|
elif command == "reset":
|
|
app.reset_sync_status()
|
|
|
|
elif command == "fullsync":
|
|
days = 365 # Default to 1 year
|
|
if len(sys.argv) > 2:
|
|
try:
|
|
days = int(sys.argv[2])
|
|
except ValueError:
|
|
print("❌ Invalid number of days. Using default 365.")
|
|
|
|
await app.setup()
|
|
await app.force_full_sync(days)
|
|
|
|
elif command == "check":
|
|
await app.setup()
|
|
app.garmin.check_garmin_weights(30)
|
|
|
|
elif command == "testupload":
|
|
# Test upload with a single fake record
|
|
await app.setup()
|
|
test_record = WeightRecord(
|
|
timestamp=datetime.now(timezone.utc),
|
|
weight_kg=70.0, # 70kg test weight
|
|
source="test"
|
|
)
|
|
success, failed = await app.garmin.upload_weight_data([test_record])
|
|
print(f"Test upload: {success} successful, {failed} failed")
|
|
|
|
elif command == "debug":
|
|
days = 30
|
|
if len(sys.argv) > 2:
|
|
try:
|
|
days = int(sys.argv[2])
|
|
except ValueError:
|
|
print("❌ Invalid number of days. Using default 30.")
|
|
await app.debug_fitbit_data(days)
|
|
|
|
|
|
elif command == "config":
|
|
read_only_mode = app.config.get('sync.read_only_mode', False)
|
|
print(f"📁 Configuration file: {app.config.config_file}")
|
|
print(f"📁 Database file: {app.config.get('database.path')}")
|
|
print(f"📁 Log file: weight_sync.log")
|
|
print(f"🔒 Read-only mode: {'Enabled' if read_only_mode else 'Disabled'}")
|
|
|
|
elif command == "readonly":
|
|
app.toggle_read_only_mode()
|
|
|
|
elif command == "schedule":
|
|
await app.setup()
|
|
try:
|
|
read_only_mode = app.config.get('sync.read_only_mode', False)
|
|
print("🚀 Starting scheduled sync...")
|
|
if read_only_mode:
|
|
print("📖 Running in read-only mode - will NOT upload to Garmin")
|
|
print("Press Ctrl+C to stop")
|
|
app.start_scheduler()
|
|
except KeyboardInterrupt:
|
|
print("\n👋 Scheduler stopped")
|
|
|
|
else:
|
|
print("❓ Unknown command. Available commands:")
|
|
print(" setup - Initial setup and authentication")
|
|
print(" sync - Run manual sync")
|
|
print(" status - Show sync status")
|
|
print(" config - Show configuration info")
|
|
print(" readonly - Toggle read-only mode")
|
|
print(" schedule - Start scheduled sync")
|
|
else:
|
|
print("🏃 Weight Sync Application")
|
|
print("Syncs weight data from Fitbit API to Garmin Connect")
|
|
print("Run with 'python fitbit_sync.py <command>'")
|
|
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()) |