This commit is contained in:
2025-10-04 16:08:55 -07:00
parent 03b5bf85aa
commit 3a4563a34d
9 changed files with 1714 additions and 127 deletions

View File

@@ -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)