152 lines
7.4 KiB
Python
152 lines
7.4 KiB
Python
import fitbit
|
|
from fitbit import exceptions
|
|
from datetime import datetime, timedelta
|
|
from typing import List, Dict, Any, Optional
|
|
import logging
|
|
import time
|
|
from ..utils.helpers import setup_logger
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
class FitbitClient:
|
|
def __init__(self, client_id: str, client_secret: str, access_token: str = None, refresh_token: str = None, redirect_uri: str = None):
|
|
self.client_id = client_id
|
|
self.client_secret = client_secret
|
|
self.access_token = access_token
|
|
self.refresh_token = refresh_token
|
|
self.redirect_uri = redirect_uri
|
|
self.fitbit = None
|
|
|
|
# Initialize Fitbit class if we have enough info, or just for auth flow
|
|
# The example initializes it immediately
|
|
if client_id and client_secret:
|
|
self.fitbit = fitbit.Fitbit(
|
|
client_id,
|
|
client_secret,
|
|
access_token=access_token,
|
|
refresh_token=refresh_token,
|
|
redirect_uri=redirect_uri,
|
|
timeout=10
|
|
)
|
|
|
|
def get_authorization_url(self, redirect_uri: str = None) -> str:
|
|
"""Generate authorization URL for Fitbit OAuth flow."""
|
|
# Update internal redirect_uri if provided
|
|
if redirect_uri:
|
|
self.redirect_uri = redirect_uri
|
|
# Re-init or update client? Fitbit class uses it in init.
|
|
# It seems simpler to recreate the instance or update the client manually if supported.
|
|
# But based on the example, we can just init with it.
|
|
self.fitbit = fitbit.Fitbit(
|
|
self.client_id,
|
|
self.client_secret,
|
|
redirect_uri=redirect_uri,
|
|
timeout=10
|
|
)
|
|
|
|
# The example calls self.fitbit.client.authorize_token_url()
|
|
# Note: The fitbit library might default to certain scopes or we need to pass them?
|
|
# The example used: url, _ = self.fitbit.client.authorize_token_url()
|
|
# But we need scopes.
|
|
|
|
scope = ['weight', 'nutrition', 'activity', 'sleep', 'heartrate', 'profile']
|
|
|
|
# The underlying client is oauthlib.oauth2.WebApplicationClient usually
|
|
auth_url, _ = self.fitbit.client.authorize_token_url(scope=scope)
|
|
|
|
logger.info(f"Generated Fitbit authorization URL: {auth_url}")
|
|
return auth_url
|
|
|
|
def exchange_code_for_token(self, code: str, redirect_uri: str = None) -> Dict[str, Any]:
|
|
"""Exchange authorization code for access and refresh tokens."""
|
|
# If redirect_uri is provided here, ensure we are using a client configured with it
|
|
if redirect_uri and redirect_uri != self.redirect_uri:
|
|
self.fitbit = fitbit.Fitbit(
|
|
self.client_id,
|
|
self.client_secret,
|
|
redirect_uri=redirect_uri,
|
|
timeout=10
|
|
)
|
|
|
|
logger.info(f"Exchanging authorization code for tokens")
|
|
|
|
# The example: self.fitbit.client.fetch_access_token(code)
|
|
# It updates the internal token automatically.
|
|
token = self.fitbit.client.fetch_access_token(code)
|
|
|
|
return token
|
|
|
|
def get_weight_logs(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
|
|
"""Fetch weight logs from Fitbit API."""
|
|
if not self.fitbit:
|
|
raise Exception("Fitbit client not authenticated")
|
|
|
|
if not end_date:
|
|
end_date = datetime.now().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
print(f"Making request to Fitbit API: get_bodyweight(base_date={start_date}, end_date={end_date})", flush=True)
|
|
|
|
# get_bodyweight returns {'weight': [...]}
|
|
weight_logs = self.fitbit.get_bodyweight(
|
|
base_date=start_date,
|
|
end_date=end_date
|
|
)
|
|
|
|
logs = weight_logs.get('weight', [])
|
|
print(f"Fitbit Response: Success. Fetched {len(logs)} weight entries.", flush=True)
|
|
return logs
|
|
except exceptions.HTTPTooManyRequests as e:
|
|
retry_after = e.retry_after_secs if hasattr(e, 'retry_after_secs') else 'unknown'
|
|
print(f"Fitbit API Rate Limit Hit! Retry-After: {retry_after} seconds.", flush=True)
|
|
if not retry_after or retry_after == 'unknown':
|
|
if hasattr(e, 'response') and e.response is not None:
|
|
retry_after = e.response.headers.get('Retry-After', 'unknown')
|
|
|
|
print(f"Rate Limited. Recommended wait: {retry_after}", flush=True)
|
|
raise e
|
|
except KeyError as e:
|
|
# Handle specific KeyError from fitbit library when parsing 429 response
|
|
if str(e).strip("'\"") == 'retry-after':
|
|
print("Fitbit Library KeyError 'retry-after' detected. This is a Rate Limit event.", flush=True)
|
|
# Raise as TooManyRequests manually
|
|
# We don't have the response object easily here unless we dig into stack,
|
|
# so we assume a safe default wait time.
|
|
print("Rate Limited (inferred). Recommended wait: 60 seconds (default).", flush=True)
|
|
# Create a mock exception or just raise HTTPTooManyRequests with limited info
|
|
# The library's exception requires a response object in init usually, but let's try to simulate or just raise generic
|
|
# Actually, better to raise the library's exception if possible, or our own wrappers.
|
|
# Let's just re-raise as the libraries exception but monkey-patch the retry_after_secs if possible?
|
|
# No, just raise it and let the caller handle it.
|
|
# Since we can't easily construct the proper exception without a response object,
|
|
# we'll raise a new HTTPTooManyRequests with a valid retry_after_secs if we can, or just let sync.py handle generic error?
|
|
# Sync.py catches Exception.
|
|
# Better: Log clearly and raise a clean error that implies rate limit so user sees it.
|
|
# AND if we want to auto-retry, we need to signal that.
|
|
|
|
# Let's assume 60s wait.
|
|
# We can construct a dummy object to hold the retry value if needed,
|
|
# but for now let's just print and raise.
|
|
# To make sync.py sleep, we can modify sync.py.
|
|
# Or here, we can sleep? No, sync.py controls the loop.
|
|
pass
|
|
# Fall through to generic Exception handler which prints it?
|
|
# No, we want to customize the message.
|
|
raise Exception("Fitbit Rate Limit Hit (Library Error). Retry-After: 60s") from e
|
|
raise e
|
|
except Exception as e:
|
|
print(f"Error fetching weight logs from Fitbit: {str(e)}", flush=True)
|
|
if hasattr(e, 'response') and e.response is not None:
|
|
print(f"Fitbit API Error Response: {e.response.text}", flush=True)
|
|
print(f"Fitbit API Error Headers: {e.response.headers}", flush=True)
|
|
raise e
|
|
|
|
def refresh_access_token(self) -> Dict[str, str]:
|
|
"""Refresh the Fitbit access token."""
|
|
# Implementation for token refresh
|
|
logger.info("Refreshing Fitbit access token")
|
|
# Return mock response for now
|
|
return {
|
|
"access_token": "new_mock_access_token",
|
|
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat()
|
|
} |