sync
This commit is contained in:
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Use an official Python runtime as a parent image
|
||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the dependencies file to the working directory
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install any needed packages specified in requirements.txt
|
||||||
|
RUN pip install --upgrade pip; pip install --no-cache-dir --upgrade -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the rest of the application's code to the working directory
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set up a volume for persistent data
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
# The command to run the application
|
||||||
|
ENTRYPOINT ["python", "fitbitsync.py"]
|
||||||
|
|
||||||
|
# Default command to run when container starts
|
||||||
|
CMD ["schedule"]
|
||||||
62
Makefile
Normal file
62
Makefile
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Makefile for Fitbit to Garmin Weight Sync Docker Application
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
IMAGE = fitbit-garmin-sync
|
||||||
|
DATA_DIR = data
|
||||||
|
VOLUME = -v "$(PWD)/$(DATA_DIR)":/app/data
|
||||||
|
CONTAINER_NAME = fitbit-sync
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
.PHONY: help
|
||||||
|
help:
|
||||||
|
@echo "Available targets:"
|
||||||
|
@echo " build - Build the Docker image"
|
||||||
|
@echo " data - Create the data directory for persistence"
|
||||||
|
@echo " run - Run the application in scheduled sync mode (detached)"
|
||||||
|
@echo " setup - Run interactive setup for credentials"
|
||||||
|
@echo " sync - Run manual sync"
|
||||||
|
@echo " status - Check application status"
|
||||||
|
@echo " stop - Stop the running container"
|
||||||
|
@echo " clean - Stop and remove container, remove image"
|
||||||
|
@echo " help - Show this help message"
|
||||||
|
|
||||||
|
# Build the Docker image
|
||||||
|
.PHONY: build
|
||||||
|
build:
|
||||||
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
.PHONY: data
|
||||||
|
data:
|
||||||
|
mkdir -p $(DATA_DIR)
|
||||||
|
|
||||||
|
# Run the scheduled sync (detached)
|
||||||
|
.PHONY: run
|
||||||
|
run: build data
|
||||||
|
docker run -d --name $(CONTAINER_NAME) $(VOLUME) $(IMAGE)
|
||||||
|
|
||||||
|
# Interactive setup
|
||||||
|
.PHONY: setup
|
||||||
|
setup: build data
|
||||||
|
docker run -it --rm $(VOLUME) $(IMAGE) setup
|
||||||
|
|
||||||
|
# Manual sync
|
||||||
|
.PHONY: sync
|
||||||
|
sync: build data
|
||||||
|
docker run -it --rm $(VOLUME) $(IMAGE) sync
|
||||||
|
|
||||||
|
# Check status
|
||||||
|
.PHONY: status
|
||||||
|
status: build data
|
||||||
|
docker run -it --rm $(VOLUME) $(IMAGE) status
|
||||||
|
|
||||||
|
# Stop the running container
|
||||||
|
.PHONY: stop
|
||||||
|
stop:
|
||||||
|
docker stop $(CONTAINER_NAME) || true
|
||||||
|
docker rm $(CONTAINER_NAME) || true
|
||||||
|
|
||||||
|
# Clean up: stop container, remove image
|
||||||
|
.PHONY: clean
|
||||||
|
clean: stop
|
||||||
|
docker rmi $(IMAGE) || true
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Fitbit to Garmin Weight Sync - Dockerized
|
||||||
|
|
||||||
|
This application syncs weight data from the Fitbit API to Garmin Connect. This README provides instructions on how to run the application using Docker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker must be installed on your system.
|
||||||
|
|
||||||
|
## Building the Docker Image
|
||||||
|
|
||||||
|
To build the Docker image for this application, run the following command from the root directory of the project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
1. **Create a data directory on your host machine:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir fitbit_garmin_data
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the Docker container with a mounted volume:**
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### Interactive Setup
|
||||||
|
|
||||||
|
The first time you run the application, you will need to perform an interactive setup to provide your Fitbit and Garmin credentials.
|
||||||
|
|
||||||
|
1. **Run the container with the `setup` command:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync setup
|
||||||
|
```
|
||||||
|
|
||||||
|
- `-it` allows you to interact with the container's terminal.
|
||||||
|
- `--rm` will remove the container after it exits.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
To check the status:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -it --rm -v "$(pwd)/fitbit_garmin_data":/app/data fitbit-garmin-sync status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
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.
|
||||||
BIN
__pycache__/fitbit.cpython-313.pyc
Normal file
BIN
__pycache__/fitbit.cpython-313.pyc
Normal file
Binary file not shown.
138
fitbitsync.py
138
fitbitsync.py
@@ -34,7 +34,7 @@ logging.basicConfig(
|
|||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.FileHandler('weight_sync.log'),
|
logging.FileHandler('data/weight_sync.log'),
|
||||||
logging.StreamHandler()
|
logging.StreamHandler()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -57,8 +57,9 @@ class WeightRecord:
|
|||||||
class ConfigManager:
|
class ConfigManager:
|
||||||
"""Manages application configuration and credentials"""
|
"""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 = Path(config_file)
|
||||||
|
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.config = self._load_config()
|
self.config = self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> Dict:
|
def _load_config(self) -> Dict:
|
||||||
@@ -90,14 +91,14 @@ class ConfigManager:
|
|||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"refresh_token": "",
|
"refresh_token": "",
|
||||||
"token_file": "fitbit_token.json",
|
"token_file": "data/fitbit_token.json",
|
||||||
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
||||||
},
|
},
|
||||||
"garmin": {
|
"garmin": {
|
||||||
"username": "",
|
"username": "",
|
||||||
"password": "",
|
"password": "",
|
||||||
"is_china": False, # Set to True if using Garmin China
|
"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": {
|
||||||
"sync_interval_minutes": 60,
|
"sync_interval_minutes": 60,
|
||||||
@@ -106,7 +107,7 @@ class ConfigManager:
|
|||||||
"read_only_mode": False # Set to True to prevent uploads to Garmin
|
"read_only_mode": False # Set to True to prevent uploads to Garmin
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"path": "weight_sync.db"
|
"path": "data/weight_sync.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
# Don't automatically save here, let the caller decide
|
# Don't automatically save here, let the caller decide
|
||||||
@@ -143,7 +144,7 @@ class ConfigManager:
|
|||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"access_token": "",
|
"access_token": "",
|
||||||
"refresh_token": "",
|
"refresh_token": "",
|
||||||
"token_file": "fitbit_token.json",
|
"token_file": "data/fitbit_token.json",
|
||||||
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
"redirect_uri": "http://localhost:8080/fitbit-callback"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,7 +543,9 @@ class GarminClient:
|
|||||||
self.username = None
|
self.username = None
|
||||||
self.password = None
|
self.password = None
|
||||||
self.is_china = config.get('garmin.is_china', False)
|
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.garmin_client = None
|
||||||
self.read_only_mode = config.get('sync.read_only_mode', False)
|
self.read_only_mode = config.get('sync.read_only_mode', False)
|
||||||
|
|
||||||
@@ -551,6 +554,29 @@ class GarminClient:
|
|||||||
import garminconnect
|
import garminconnect
|
||||||
self.garminconnect = garminconnect
|
self.garminconnect = garminconnect
|
||||||
logger.info("Using garminconnect library")
|
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:
|
except ImportError:
|
||||||
logger.error("garminconnect library not installed. Install with: pip install garminconnect")
|
logger.error("garminconnect library not installed. Install with: pip install garminconnect")
|
||||||
raise ImportError("garminconnect library is required but not installed")
|
raise ImportError("garminconnect library is required but not installed")
|
||||||
@@ -560,57 +586,73 @@ class GarminClient:
|
|||||||
if self.read_only_mode:
|
if self.read_only_mode:
|
||||||
logger.info("Running in read-only mode - skipping Garmin authentication")
|
logger.info("Running in read-only mode - skipping Garmin authentication")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get credentials from config
|
# Get credentials from config
|
||||||
self.username = self.config.get_credentials('garmin', 'username')
|
self.username = self.config.get_credentials('garmin', 'username')
|
||||||
self.password = self.config.get_credentials('garmin', 'password')
|
self.password = self.config.get_credentials('garmin', 'password')
|
||||||
|
|
||||||
if not self.username or not self.password:
|
if not self.username or not self.password:
|
||||||
logger.info("No stored Garmin credentials found. Please set them up.")
|
logger.info("No stored Garmin credentials found. Please set them up.")
|
||||||
return self._setup_credentials()
|
if not self._setup_credentials():
|
||||||
|
return False
|
||||||
# Create Garmin Connect client
|
|
||||||
logger.info("Authenticating with Garmin Connect...")
|
logger.info("Initializing Garmin client...")
|
||||||
|
|
||||||
# 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
|
|
||||||
self.garmin_client = self.garminconnect.Garmin(
|
self.garmin_client = self.garminconnect.Garmin(
|
||||||
self.username,
|
self.username,
|
||||||
self.password,
|
self.password,
|
||||||
is_cn=self.is_china
|
is_cn=self.is_china
|
||||||
)
|
)
|
||||||
|
|
||||||
# Login and save session
|
# Use garth to load the session if it exists
|
||||||
self.garmin_client.login()
|
if os.path.exists(self.session_file):
|
||||||
|
try:
|
||||||
# Save session data
|
logger.info(f"Attempting to load session from {self.session_file}")
|
||||||
self.garmin_client.save(self.session_file)
|
self.garmin_client.garth.load(self.session_file)
|
||||||
|
logger.info("Loaded existing session from file.")
|
||||||
# Test the connection
|
# 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()
|
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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Garmin authentication error: {e}")
|
logger.error(f"Garmin authentication error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _setup_credentials(self) -> bool:
|
def _setup_credentials(self) -> bool:
|
||||||
"""Setup Garmin credentials interactively"""
|
"""Setup Garmin credentials interactively"""
|
||||||
print("\n🔑 Garmin Connect Credentials Setup")
|
print("\n🔑 Garmin Connect Credentials Setup")
|
||||||
@@ -750,7 +792,8 @@ class GarminClient:
|
|||||||
try:
|
try:
|
||||||
logger.info("Attempting to re-authenticate...")
|
logger.info("Attempting to re-authenticate...")
|
||||||
self.garmin_client.login()
|
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
|
# Retry the upload
|
||||||
result = self.garmin_client.add_body_composition(
|
result = self.garmin_client.add_body_composition(
|
||||||
@@ -878,9 +921,14 @@ class GarminClient:
|
|||||||
class WeightSyncApp:
|
class WeightSyncApp:
|
||||||
"""Main application class"""
|
"""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.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.fitbit = FitbitClient(self.config)
|
||||||
self.garmin = GarminClient(self.config)
|
self.garmin = GarminClient(self.config)
|
||||||
|
|
||||||
|
|||||||
1415
fitbitsync_debug.py
1415
fitbitsync_debug.py
File diff suppressed because it is too large
Load Diff
109
fitsync_garthtest.py
Normal file
109
fitsync_garthtest.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import garth
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
# Load configuration
|
||||||
|
with open('config.json') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
|
||||||
|
garmin_config = config.get('garmin', {})
|
||||||
|
username = garmin_config.get('username')
|
||||||
|
password = garmin_config.get('password')
|
||||||
|
is_china = garmin_config.get('is_china', False)
|
||||||
|
session_file = garmin_config.get('session_data_file', 'garmin_session.json')
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
print("❌ Missing Garmin credentials in config.json")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Set domain based on region
|
||||||
|
domain = "garmin.cn" if is_china else "garmin.com"
|
||||||
|
garth.configure(domain=domain)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Authentication attempt
|
||||||
|
start_time = time.time()
|
||||||
|
garth.login(username, password)
|
||||||
|
end_time = time.time()
|
||||||
|
|
||||||
|
print("✅ Authentication successful!")
|
||||||
|
print(f"⏱️ Authentication time: {end_time - start_time:.2f} seconds")
|
||||||
|
garth.save(session_file)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except garth.exc.GarthHTTPError as e:
|
||||||
|
end_time = time.time() # Capture time when error occurred
|
||||||
|
# Extract information from the exception message
|
||||||
|
error_msg = str(e)
|
||||||
|
print(f"\n⚠️ Garth HTTP Error: {error_msg}")
|
||||||
|
|
||||||
|
# Check if it's a 429 error by parsing the message
|
||||||
|
if "429" in error_msg:
|
||||||
|
print("=" * 50)
|
||||||
|
print("Rate Limit Exceeded (429)")
|
||||||
|
print(f"Response Time: {end_time - start_time:.2f} seconds")
|
||||||
|
|
||||||
|
# Try to extract URL from error message
|
||||||
|
url_start = error_msg.find("url: ")
|
||||||
|
if url_start != -1:
|
||||||
|
url = error_msg[url_start + 5:]
|
||||||
|
print(f"URL: {url}")
|
||||||
|
|
||||||
|
# Try to access response headers if available
|
||||||
|
if hasattr(e, 'response') and e.response is not None:
|
||||||
|
headers = e.response.headers
|
||||||
|
retry_after = headers.get('Retry-After')
|
||||||
|
reset_timestamp = headers.get('X-RateLimit-Reset')
|
||||||
|
remaining = headers.get('X-RateLimit-Remaining')
|
||||||
|
limit = headers.get('X-RateLimit-Limit')
|
||||||
|
|
||||||
|
print("\n📊 Rate Limit Headers Found:")
|
||||||
|
if retry_after:
|
||||||
|
print(f"⏳ Retry-After: {retry_after} seconds")
|
||||||
|
wait_time = int(retry_after)
|
||||||
|
reset_time = datetime.utcfromtimestamp(time.time() + wait_time)
|
||||||
|
print(f"⏰ Estimated reset time: {reset_time} UTC")
|
||||||
|
if reset_timestamp:
|
||||||
|
try:
|
||||||
|
reset_time = datetime.utcfromtimestamp(float(reset_timestamp))
|
||||||
|
print(f"⏰ Rate limit resets at: {reset_time} UTC")
|
||||||
|
except ValueError:
|
||||||
|
print(f"⚠️ Invalid reset timestamp: {reset_timestamp}")
|
||||||
|
if remaining:
|
||||||
|
print(f"🔄 Requests remaining: {remaining}")
|
||||||
|
if limit:
|
||||||
|
print(f"📈 Rate limit: {limit} requests per window")
|
||||||
|
if not any([retry_after, reset_timestamp, remaining, limit]):
|
||||||
|
print("ℹ️ No rate limit headers found in response")
|
||||||
|
else:
|
||||||
|
print("\n⚠️ Response headers not accessible directly from GarthHTTPError")
|
||||||
|
print("Common rate limit headers to look for:")
|
||||||
|
print(" - Retry-After: Seconds to wait before retrying")
|
||||||
|
print(" - X-RateLimit-Limit: Maximum requests per time window")
|
||||||
|
print(" - X-RateLimit-Remaining: Remaining requests in current window")
|
||||||
|
print(" - X-RateLimit-Reset: Time when rate limit resets")
|
||||||
|
print(f"\nRecommend waiting at least 60 seconds before retrying.")
|
||||||
|
|
||||||
|
return 429
|
||||||
|
|
||||||
|
# Handle other HTTP errors
|
||||||
|
print(f"❌ HTTP Error detected: {error_msg}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Authentication failed: {str(e)}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("❌ config.json file not found")
|
||||||
|
return 1
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print("❌ Error parsing config.json")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fitbit==0.3.1
|
||||||
|
garminconnect==0.2.30
|
||||||
|
garth==0.5.17
|
||||||
|
schedule==1.2.2
|
||||||
Reference in New Issue
Block a user