sync
This commit is contained in:
138
fitbitsync.py
138
fitbitsync.py
@@ -34,7 +34,7 @@ logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('weight_sync.log'),
|
||||
logging.FileHandler('data/weight_sync.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
@@ -57,8 +57,9 @@ class WeightRecord:
|
||||
class ConfigManager:
|
||||
"""Manages application configuration and credentials"""
|
||||
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
def __init__(self, config_file: str = "data/config.json"):
|
||||
self.config_file = Path(config_file)
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.config = self._load_config()
|
||||
|
||||
def _load_config(self) -> Dict:
|
||||
@@ -90,14 +91,14 @@ class ConfigManager:
|
||||
"client_secret": "",
|
||||
"access_token": "",
|
||||
"refresh_token": "",
|
||||
"token_file": "fitbit_token.json",
|
||||
"token_file": "data/fitbit_token.json",
|
||||
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
||||
},
|
||||
"garmin": {
|
||||
"username": "",
|
||||
"password": "",
|
||||
"is_china": False, # Set to True if using Garmin China
|
||||
"session_data_file": "garmin_session.json"
|
||||
"session_data_file": "data/garmin_session.json"
|
||||
},
|
||||
"sync": {
|
||||
"sync_interval_minutes": 60,
|
||||
@@ -106,7 +107,7 @@ class ConfigManager:
|
||||
"read_only_mode": False # Set to True to prevent uploads to Garmin
|
||||
},
|
||||
"database": {
|
||||
"path": "weight_sync.db"
|
||||
"path": "data/weight_sync.db"
|
||||
}
|
||||
}
|
||||
# Don't automatically save here, let the caller decide
|
||||
@@ -143,7 +144,7 @@ class ConfigManager:
|
||||
"client_secret": "",
|
||||
"access_token": "",
|
||||
"refresh_token": "",
|
||||
"token_file": "fitbit_token.json",
|
||||
"token_file": "data/fitbit_token.json",
|
||||
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
||||
}
|
||||
|
||||
@@ -542,7 +543,9 @@ class GarminClient:
|
||||
self.username = None
|
||||
self.password = None
|
||||
self.is_china = config.get('garmin.is_china', False)
|
||||
self.session_file = config.get('garmin.session_data_file', 'garmin_session.json')
|
||||
# Resolve session file path relative to config file location
|
||||
session_file_rel = config.get('garmin.session_data_file', 'garmin_session.json')
|
||||
self.session_file = config.config_file.parent / session_file_rel
|
||||
self.garmin_client = None
|
||||
self.read_only_mode = config.get('sync.read_only_mode', False)
|
||||
|
||||
@@ -551,6 +554,29 @@ class GarminClient:
|
||||
import garminconnect
|
||||
self.garminconnect = garminconnect
|
||||
logger.info("Using garminconnect library")
|
||||
|
||||
# Monkey patch the login method to handle garth compatibility issue
|
||||
original_login = self.garminconnect.Garmin.login
|
||||
|
||||
def patched_login(self):
|
||||
"""Patched login method that handles garth returning None"""
|
||||
try:
|
||||
result = original_login(self)
|
||||
return result
|
||||
except TypeError as e:
|
||||
if "cannot unpack non-iterable NoneType object" in str(e):
|
||||
# Check if we have valid tokens despite the None return
|
||||
if (self.garth.oauth1_token and self.garth.oauth2_token):
|
||||
logger.info("Login successful (handled garth None return)")
|
||||
return True
|
||||
else:
|
||||
raise
|
||||
else:
|
||||
raise
|
||||
|
||||
# Apply the patch
|
||||
self.garminconnect.Garmin.login = patched_login
|
||||
|
||||
except ImportError:
|
||||
logger.error("garminconnect library not installed. Install with: pip install garminconnect")
|
||||
raise ImportError("garminconnect library is required but not installed")
|
||||
@@ -560,57 +586,73 @@ class GarminClient:
|
||||
if self.read_only_mode:
|
||||
logger.info("Running in read-only mode - skipping Garmin authentication")
|
||||
return True
|
||||
|
||||
|
||||
try:
|
||||
# Get credentials from config
|
||||
self.username = self.config.get_credentials('garmin', 'username')
|
||||
self.password = self.config.get_credentials('garmin', 'password')
|
||||
|
||||
|
||||
if not self.username or not self.password:
|
||||
logger.info("No stored Garmin credentials found. Please set them up.")
|
||||
return self._setup_credentials()
|
||||
|
||||
# Create Garmin Connect client
|
||||
logger.info("Authenticating with Garmin Connect...")
|
||||
|
||||
# Try to load existing session first
|
||||
if os.path.exists(self.session_file):
|
||||
try:
|
||||
self.garmin_client = self.garminconnect.Garmin()
|
||||
self.garmin_client.load(self.session_file)
|
||||
|
||||
# Test the session by trying to get profile
|
||||
profile = self.garmin_client.get_full_name()
|
||||
logger.info(f"Resumed existing session for user: {profile}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Existing session invalid: {e}")
|
||||
# Fall through to fresh login
|
||||
|
||||
# Perform fresh login
|
||||
if not self._setup_credentials():
|
||||
return False
|
||||
|
||||
logger.info("Initializing Garmin client...")
|
||||
self.garmin_client = self.garminconnect.Garmin(
|
||||
self.username,
|
||||
self.password,
|
||||
is_cn=self.is_china
|
||||
)
|
||||
|
||||
# Login and save session
|
||||
self.garmin_client.login()
|
||||
|
||||
# Save session data
|
||||
self.garmin_client.save(self.session_file)
|
||||
|
||||
# Test the connection
|
||||
|
||||
# Use garth to load the session if it exists
|
||||
if os.path.exists(self.session_file):
|
||||
try:
|
||||
logger.info(f"Attempting to load session from {self.session_file}")
|
||||
self.garmin_client.garth.load(self.session_file)
|
||||
logger.info("Loaded existing session from file.")
|
||||
# Log garth state after loading
|
||||
logger.info(f"Garth tokens after load: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load session file: {e}. Performing fresh login.")
|
||||
|
||||
# Login (will use loaded session or perform a fresh auth)
|
||||
logger.info("Calling garmin_client.login()...")
|
||||
try:
|
||||
# Handle garth API compatibility issue - newer versions return None
|
||||
# when using existing sessions, but garminconnect expects a tuple
|
||||
login_result = self.garmin_client.login()
|
||||
|
||||
# Check if login returned None (new garth behavior with existing sessions)
|
||||
if login_result is None:
|
||||
# Verify that we actually have valid tokens after login
|
||||
if (self.garmin_client.garth.oauth1_token and
|
||||
self.garmin_client.garth.oauth2_token):
|
||||
logger.info("Login successful (garth returned None but tokens are valid)")
|
||||
else:
|
||||
logger.error("Login failed - garth returned None and no valid tokens")
|
||||
raise Exception("Garmin login failed: No valid tokens after authentication")
|
||||
else:
|
||||
logger.info("Login successful")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Login failed with exception: {e}")
|
||||
# Log garth state before re-raising
|
||||
logger.info(f"Garth tokens before failure: oauth1={bool(self.garmin_client.garth.oauth1_token)}, oauth2={bool(self.garmin_client.garth.oauth2_token)}")
|
||||
raise
|
||||
|
||||
# Save the session using garth's dump method
|
||||
self.garmin_client.garth.dump(self.session_file)
|
||||
|
||||
profile = self.garmin_client.get_full_name()
|
||||
logger.info(f"Successfully authenticated for user: {profile}")
|
||||
|
||||
logger.info(f"Successfully authenticated and saved session for user: {profile}")
|
||||
return True
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Garmin authentication error: {e}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
return False
|
||||
|
||||
|
||||
def _setup_credentials(self) -> bool:
|
||||
"""Setup Garmin credentials interactively"""
|
||||
print("\n🔑 Garmin Connect Credentials Setup")
|
||||
@@ -750,7 +792,8 @@ class GarminClient:
|
||||
try:
|
||||
logger.info("Attempting to re-authenticate...")
|
||||
self.garmin_client.login()
|
||||
self.garmin_client.save_session(self.session_file)
|
||||
# Correctly save the new session data using garth
|
||||
self.garmin_client.garth.dump(self.session_file)
|
||||
|
||||
# Retry the upload
|
||||
result = self.garmin_client.add_body_composition(
|
||||
@@ -878,9 +921,14 @@ class GarminClient:
|
||||
class WeightSyncApp:
|
||||
"""Main application class"""
|
||||
|
||||
def __init__(self, config_file: str = "config.json"):
|
||||
def __init__(self, config_file: str = "data/config.json"):
|
||||
self.config = ConfigManager(config_file)
|
||||
self.db = DatabaseManager(self.config.get('database.path'))
|
||||
|
||||
# Construct full paths for data files
|
||||
data_dir = self.config.config_file.parent
|
||||
db_path = data_dir / self.config.get('database.path', 'weight_sync.db')
|
||||
|
||||
self.db = DatabaseManager(db_path)
|
||||
self.fitbit = FitbitClient(self.config)
|
||||
self.garmin = GarminClient(self.config)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user