before claude fix #1

This commit is contained in:
2025-12-23 06:09:34 -08:00
parent c505fb69a6
commit a23fa1b30d
83 changed files with 5682 additions and 0 deletions

14
FitnessSync/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
WORKDIR /app/backend
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

178
FitnessSync/README.md Normal file
View File

@@ -0,0 +1,178 @@
# Fitbit-Garmin Local Sync
A standalone Python application designed to synchronize health and fitness data between the Fitbit and Garmin Connect platforms. The primary functions are to transfer weight data from Fitbit to Garmin, archive activity files from Garmin to a local directory, and download a wide range of Garmin health metrics for local storage and analysis.
## Features
- **Weight Data Synchronization**: Fetches weight history from Fitbit API and uploads to Garmin Connect
- **Activity File Archiving**: Downloads original activity files (.fit, .gpx, .tcx) from Garmin and stores them in the PostgreSQL database
- **Health Metrics Download**: Retrieves comprehensive health metrics from Garmin Connect
- **Web Interface**: Simple browser-based UI for triggering sync operations
- **Local-Only Storage**: All sensitive data stored locally with no external cloud services
## Prerequisites
- Python 3.11+
- PostgreSQL database
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account (to create an app and get API credentials)
- Garmin Connect Account
## Setup
### 1. Clone and Install Dependencies
```bash
# Clone the repository
git clone <repository-url>
cd fitbit-garmin-sync
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### 2. Database Setup
```bash
# Create PostgreSQL database
createdb fitbit_garmin_sync
# Update database configuration in application
# The application will handle schema creation automatically
```
### 3. Environment Configuration
Create a `.env` file with the following:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
```
### 4. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
# Or using Docker
docker-compose up --build
```
## Initial Configuration
1. Open the application in your browser at `http://localhost:8000`
2. Navigate to the Setup page (`/setup`)
3. Enter your Garmin Connect username and password
4. Enter your Fitbit Client ID and Client Secret
5. Click the authorization link provided to authenticate with Fitbit
6. Copy the full callback URL from your browser after authorizing and paste it into the input field on the setup page
## Usage
### Sync Weight Data
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### Archive Activities
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. Monitor the sync status in the logs table
### View Health Metrics
1. Use the API endpoints to query health metrics:
- `/api/metrics/list` - List available metric types
- `/api/metrics/query` - Query specific metrics
- `/api/health-data/summary` - Get aggregated health statistics
## API Endpoints
See the full API documentation in the `specs/001-fitbit-garmin-sync/contracts/api-contract.yaml` file or access the automatic documentation at `/docs` when running the application.
## Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up --build
# The application will be available at http://localhost:8000
# PostgreSQL database will be automatically set up
```
## Architecture
### Project Structure
```
backend/
├── main.py
├── src/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── weight_record.py
│ │ ├── activity.py
│ │ ├── health_metric.py
│ │ ├── sync_log.py
│ │ └── api_token.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── fitbit_client.py
│ │ ├── garmin_client.py
│ │ ├── postgresql_manager.py
│ │ └── sync_app.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── sync.py
│ │ ├── setup.py
│ │ └── metrics.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── templates/
│ ├── index.html
│ └── setup.html
├── static/
│ ├── css/
│ └── js/
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
tests/
├── unit/
│ ├── test_models/
│ ├── test_services/
│ └── test_api/
├── integration/
│ └── test_sync_flow.py
└── contract/
└── test_api_contracts.py
```
## Development
For development, run the application with auto-reload:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Security
- All API credentials and tokens are encrypted in the database
- OAuth2 flows are implemented following security best practices
- No external cloud services are used for data storage
- Implements rate limiting to be respectful to API providers

View File

@@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1,178 @@
# Fitbit-Garmin Local Sync
A standalone Python application designed to synchronize health and fitness data between the Fitbit and Garmin Connect platforms. The primary functions are to transfer weight data from Fitbit to Garmin, archive activity files from Garmin to a local directory, and download a wide range of Garmin health metrics for local storage and analysis.
## Features
- **Weight Data Synchronization**: Fetches weight history from Fitbit API and uploads to Garmin Connect
- **Activity File Archiving**: Downloads original activity files (.fit, .gpx, .tcx) from Garmin to local storage
- **Health Metrics Download**: Retrieves comprehensive health metrics from Garmin Connect
- **Web Interface**: Simple browser-based UI for triggering sync operations
- **Local-Only Storage**: All sensitive data stored locally with no external cloud services
## Prerequisites
- Python 3.11+
- PostgreSQL database
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account (to create an app and get API credentials)
- Garmin Connect Account
## Setup
### 1. Clone and Install Dependencies
```bash
# Clone the repository
git clone <repository-url>
cd fitbit-garmin-sync
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### 2. Database Setup
```bash
# Create PostgreSQL database
createdb fitbit_garmin_sync
# Update database configuration in application
# The application will handle schema creation automatically
```
### 3. Environment Configuration
Create a `.env` file with the following:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
```
### 4. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
# Or using Docker
docker-compose up --build
```
## Initial Configuration
1. Open the application in your browser at `http://localhost:8000`
2. Navigate to the Setup page (`/setup`)
3. Enter your Garmin Connect username and password
4. Enter your Fitbit Client ID and Client Secret
5. Click the authorization link provided to authenticate with Fitbit
6. Copy the full callback URL from your browser after authorizing and paste it into the input field on the setup page
## Usage
### Sync Weight Data
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### Archive Activities
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. Monitor the sync status in the logs table
### View Health Metrics
1. Use the API endpoints to query health metrics:
- `/api/metrics/list` - List available metric types
- `/api/metrics/query` - Query specific metrics
- `/api/health-data/summary` - Get aggregated health statistics
## API Endpoints
See the full API documentation in the `specs/001-fitbit-garmin-sync/contracts/api-contract.yaml` file or access the automatic documentation at `/docs` when running the application.
## Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up --build
# The application will be available at http://localhost:8000
# PostgreSQL database will be automatically set up
```
## Architecture
### Project Structure
```
backend/
├── main.py
├── src/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── weight_record.py
│ │ ├── activity.py
│ │ ├── health_metric.py
│ │ ├── sync_log.py
│ │ └── api_token.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── fitbit_client.py
│ │ ├── garmin_client.py
│ │ ├── postgresql_manager.py
│ │ └── sync_app.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── sync.py
│ │ ├── setup.py
│ │ └── metrics.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── templates/
│ ├── index.html
│ └── setup.html
├── static/
│ ├── css/
│ └── js/
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
tests/
├── unit/
│ ├── test_models/
│ ├── test_services/
│ └── test_api/
├── integration/
│ └── test_sync_flow.py
└── contract/
└── test_api_contracts.py
```
## Development
For development, run the application with auto-reload:
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
## Security
- All API credentials and tokens are encrypted in the database
- OAuth2 flows are implemented following security best practices
- No external cloud services are used for data storage
- Implements rate limiting to be respectful to API providers

Binary file not shown.

View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url =
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -0,0 +1 @@
Generic single-database configuration.

View File

@@ -0,0 +1,80 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the database URL from environment variable if available
database_url = os.getenv("DATABASE_URL")
if database_url:
config.set_main_option("sqlalchemy.url", database_url)
# Import our models to ensure they are registered with Base
from src.models import Base
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,151 @@
"""Initial migration
Revision ID: 24df1381ac00
Revises:
Create Date: 2025-12-22 15:04:54.280508
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '24df1381ac00'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create configurations table
op.create_table('configurations',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(), nullable=False),
sa.Column('value', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('key')
)
op.create_index(op.f('ix_configurations_id'), 'configurations', ['id'], unique=False)
# Create api_tokens table
op.create_table('api_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token_type', sa.String(), nullable=False),
sa.Column('access_token', sa.String(), nullable=False),
sa.Column('refresh_token', sa.String(), nullable=True),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('scopes', sa.String(), nullable=True),
sa.Column('garth_oauth1_token', sa.String(), nullable=True),
sa.Column('garth_oauth2_token', sa.String(), nullable=True),
sa.Column('last_used', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_api_tokens_id'), 'api_tokens', ['id'], unique=False)
# Create auth_status table
op.create_table('auth_status',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('service', sa.String(), nullable=False),
sa.Column('is_authenticated', sa.Boolean(), nullable=False),
sa.Column('last_sync', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('service')
)
op.create_index(op.f('ix_auth_status_id'), 'auth_status', ['id'], unique=False)
# Create weight_records table
op.create_table('weight_records',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('weight', sa.Float(), nullable=False),
sa.Column('bmi', sa.Float(), nullable=True),
sa.Column('body_fat', sa.Float(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_weight_records_id'), 'weight_records', ['id'], unique=False)
# Create activities table
op.create_table('activities',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('activity_name', sa.String(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('duration', sa.Integer(), nullable=True),
sa.Column('calories', sa.Integer(), nullable=True),
sa.Column('distance', sa.Float(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('activity_data', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_activities_id'), 'activities', ['id'], unique=False)
# Create health_metrics table
op.create_table('health_metrics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.Date(), nullable=False),
sa.Column('metric_type', sa.String(), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('unit', sa.String(), nullable=True),
sa.Column('source', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_health_metrics_id'), 'health_metrics', ['id'], unique=False)
# Create sync_logs table
op.create_table('sync_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sync_type', sa.String(), nullable=False),
sa.Column('source', sa.String(), nullable=False),
sa.Column('destination', sa.String(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('records_synced', sa.Integer(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_sync_logs_id'), 'sync_logs', ['id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_sync_logs_id'), table_name='sync_logs')
op.drop_table('sync_logs')
op.drop_index(op.f('ix_health_metrics_id'), table_name='health_metrics')
op.drop_table('health_metrics')
op.drop_index(op.f('ix_activities_id'), table_name='activities')
op.drop_table('activities')
op.drop_index(op.f('ix_weight_records_id'), table_name='weight_records')
op.drop_table('weight_records')
op.drop_index(op.f('ix_auth_status_id'), table_name='auth_status')
op.drop_table('auth_status')
op.drop_index(op.f('ix_api_tokens_id'), table_name='api_tokens')
op.drop_table('api_tokens')
op.drop_index(op.f('ix_configurations_id'), table_name='configurations')
op.drop_table('configurations')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add_mfa_session_fields_to_api_tokens
Revision ID: ce0f0282a142
Revises: 24df1381ac00
Create Date: 2025-12-22 18:06:20.525940
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ce0f0282a142'
down_revision: Union[str, None] = '24df1381ac00'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add MFA session columns to api_tokens table
op.add_column('api_tokens', sa.Column('mfa_session_id', sa.String(), nullable=True))
op.add_column('api_tokens', sa.Column('mfa_resume_data', sa.String(), nullable=True))
op.add_column('api_tokens', sa.Column('mfa_expires_at', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove MFA session columns from api_tokens table
op.drop_column('api_tokens', 'mfa_session_id')
op.drop_column('api_tokens', 'mfa_resume_data')
op.drop_column('api_tokens', 'mfa_expires_at')

View File

@@ -0,0 +1,31 @@
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync
- FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID:-}
- FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET:-}
- FITBIT_REDIRECT_URI=${FITBIT_REDIRECT_URI:-http://localhost:8000/api/setup/fitbit/callback}
depends_on:
- db
volumes:
- ./data:/app/data # For activity files
- ./logs:/app/logs # For application logs
db:
image: postgres:15
environment:
- POSTGRES_DB=fitbit_garmin_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

View File

@@ -0,0 +1,162 @@
# Fitbit-Garmin Sync Setup Guide
## Prerequisites
Before setting up the Fitbit-Garmin Sync application, ensure you have the following:
- Python 3.11+ installed
- PostgreSQL database server
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account to create an API application
- Garmin Connect Account
## Quick Setup with Docker
The easiest way to get started is using Docker Compose:
```bash
# Navigate to the backend directory
cd backend
# Start the application and database
docker-compose up --build
```
The application will be available at `http://localhost:8000`.
## Manual Setup
### 1. Clone the Repository
```bash
git clone <repository-url>
cd fitbit-garmin-sync
```
### 2. Create Virtual Environment
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
### 3. Install Dependencies
```bash
pip install -r requirements.txt
```
### 4. Database Setup
Create a PostgreSQL database for the application:
```bash
createdb fitbit_garmin_sync
```
### 5. Environment Configuration
Create a `.env` file in the backend directory with the following content:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
DEBUG=True
```
### 6. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
```
## Configuration Steps
Once the application is running, you'll need to configure both Fitbit and Garmin access:
### 1. Garmin Connect Setup
1. Navigate to the Setup page at `http://localhost:8000/setup`
2. Enter your Garmin Connect username and password
3. Click "Save Garmin Credentials"
### 2. Fitbit API Setup
1. Go to the [Fitbit Developer Portal](https://dev.fitbit.com/)
2. Create a new application
3. Set the OAuth 2.0 Application Type to "Personal"
4. Set the Callback URL to `http://localhost:8000/api/setup/fitbit/callback`
5. Note down the "Client ID" and "Client Secret"
6. On the setup page, enter these values in the Fitbit API Credentials section
7. Click "Save Fitbit Credentials"
8. Click the authorization link that appears to connect your Fitbit account
9. After authorizing, copy the complete URL from your browser and paste it in the "Complete Fitbit OAuth Flow" section
10. Click "Complete OAuth Flow"
## Running the Synchronization
### 1. Weight Sync
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### 2. Activity Archiving
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. The original activity files (.fit, .gpx, .tcx) will be downloaded from Garmin and stored in the PostgreSQL database
5. Monitor the sync status in the logs table
6. Use the "List Stored Activities" and "Download Activity File" options to access stored activity files
### 3. Health Metrics Sync
1. Go to the home page (`/`)
2. Click the "Sync Health Metrics" button
3. Monitor the sync status in the logs table
## Security Considerations
- Store API credentials securely (not in version control)
- Use environment variables for configuration
- Encrypt sensitive data stored in the database
- Regularly rotate API tokens
- Implement proper error handling to avoid information disclosure
## Troubleshooting
### Common Issues
- **Database Connection**: Ensure PostgreSQL is running and accessible
- **API Credentials**: Verify Fitbit and Garmin credentials are correct
- **Rate Limits**: Be mindful of API rate limits from both providers
- **Network Issues**: Ensure the application has internet access
### Logs
Check the application logs for errors:
```bash
# In Docker
docker-compose logs app
# For direct Python execution, add logging to track operations
```
## Updating
To update to the latest version:
```bash
# Pull latest changes
git pull origin main
# Update dependencies
pip install -r requirements.txt
# Restart the application
```

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""
Script to initialize the database tables
"""
import os
from src.services.postgresql_manager import PostgreSQLManager
def init_database():
print("Initializing database...")
# Use the same DATABASE_URL as in the docker-compose
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
print(f"Using database URL: {database_url}")
db_manager = PostgreSQLManager(database_url=database_url)
db_manager.init_db()
print("Database initialized successfully!")
if __name__ == "__main__":
init_database()

View File

@@ -0,0 +1,61 @@
from fastapi import FastAPI, Request
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from src.services.postgresql_manager import PostgreSQLManager
from alembic.config import Config
from alembic import command
import os
# Create application lifespan to handle startup/shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
# Run database migrations
alembic_cfg = Config("alembic.ini")
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
command.upgrade(alembic_cfg, "head")
# Initialize database tables
db_manager = PostgreSQLManager(database_url=database_url)
db_manager.init_db()
yield
# Shutdown
# Add any cleanup code here if needed
# Create FastAPI app with lifespan
app = FastAPI(lifespan=lifespan)
# Mount static files
app.mount("/static", StaticFiles(directory="static"), name="static")
# Initialize templates
templates = Jinja2Templates(directory="templates")
# Include API routes
from src.api.status import router as status_router
from src.api.sync import router as sync_router
from src.api.setup import router as setup_router
from src.api.logs import router as logs_router
from src.api.metrics import router as metrics_router
from src.api.activities import router as activities_router
app.include_router(status_router, prefix="/api")
app.include_router(sync_router, prefix="/api")
app.include_router(setup_router, prefix="/api")
app.include_router(logs_router, prefix="/api")
app.include_router(metrics_router, prefix="/api")
app.include_router(activities_router, prefix="/api")
from fastapi import Request
@app.get("/")
async def read_root(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/setup")
async def setup_page(request: Request):
return templates.TemplateResponse("setup.html", {"request": request})

View File

@@ -0,0 +1,34 @@
[tool.black]
line-length = 88
target-version = ['py311']
include = '\.pyi?$'
extend-exclude = '''
/(
# directories
\.eggs
| \.git
| \.venv
| venv
| build
| dist
| __pycache__
)/
'''
[tool.pytest.ini_options]
testpaths = ["backend/tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
asyncio_mode = "auto"
[tool.flake8]
max-line-length = 88
extend-ignore = ['E203', 'W503']
exclude = [
".git",
"__pycache__",
"build",
"dist",
".venv"
]

View File

@@ -0,0 +1,17 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
garminconnect==0.2.30
garth==0.5.17
fitbit==0.3.1
sqlalchemy==2.0.23
asyncpg==0.29.0
psycopg2-binary==2.9.9
jinja2==3.1.2
python-dotenv==1.0.0
pydantic==2.1.1
requests==2.31.0
httpx==0.25.2
aiofiles==23.2.1
pytest==7.4.3
pytest-asyncio==0.21.1
alembic==1.13.1

View File

View File

View File

@@ -0,0 +1,44 @@
from fastapi import APIRouter, Query, Response
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
router = APIRouter()
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
activity_name: Optional[str] = None
activity_type: Optional[str] = None
start_time: Optional[str] = None
duration: Optional[int] = None
# file_path removed since we store in DB
file_type: Optional[str] = None
download_status: Optional[str] = None
downloaded_at: Optional[str] = None
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []
@router.get("/activities/download/{activity_id}")
async def download_activity(activity_id: str):
# This would serve the stored activity file from the database
# Implementation will connect with the services layer
# It should return the file content with appropriate content-type
return Response(content=b"sample_content", media_type="application/octet-stream", headers={"Content-Disposition": f"attachment; filename=activity_{activity_id}.tcx"})

View File

@@ -0,0 +1,24 @@
from fastapi import APIRouter, Query
from pydantic import BaseModel
from typing import List, Optional
router = APIRouter()
class SyncLogResponse(BaseModel):
id: int
operation: str
status: str
message: Optional[str]
start_time: str
end_time: Optional[str]
records_processed: int
records_failed: int
@router.get("/logs", response_model=List[SyncLogResponse])
async def get_logs(
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0)
):
# This would return sync logs
# Implementation will connect with the services layer
return []

View File

@@ -0,0 +1,98 @@
from fastapi import APIRouter, Query
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
router = APIRouter()
class HealthMetricResponse(BaseModel):
id: int
metric_type: str
metric_value: float
unit: Optional[str]
timestamp: str
date: str
source: str
detailed_data: Optional[Dict[str, Any]]
class MetricDateRange(BaseModel):
start_date: Optional[str]
end_date: Optional[str]
class MetricsListResponse(BaseModel):
metric_types: List[str]
date_range: MetricDateRange
class HealthDataSummary(BaseModel):
total_steps: Optional[int] = 0
avg_heart_rate: Optional[float] = 0.0
total_sleep_hours: Optional[float] = 0.0
avg_calories: Optional[float] = 0.0
class ActivityResponse(BaseModel):
id: Optional[int] = None
garmin_activity_id: Optional[str] = None
activity_name: Optional[str] = None
activity_type: Optional[str] = None
start_time: Optional[str] = None
duration: Optional[int] = None
file_path: Optional[str] = None
file_type: Optional[str] = None
download_status: Optional[str] = None
downloaded_at: Optional[str] = None
@router.get("/metrics/list", response_model=MetricsListResponse)
async def list_available_metrics():
# This would return available metric types and date ranges
# Implementation will connect with the services layer
return {
"metric_types": ["steps", "heart_rate", "sleep", "calories"],
"date_range": {
"start_date": "2023-01-01",
"end_date": "2023-12-31"
}
}
@router.get("/metrics/query", response_model=List[HealthMetricResponse])
async def query_metrics(
metric_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=1000)
):
# This would query health metrics with filters
# Implementation will connect with the services layer
return []
@router.get("/health-data/summary", response_model=HealthDataSummary)
async def get_health_summary(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None)
):
# This would return aggregated health statistics
# Implementation will connect with the services layer
return {
"total_steps": 123456,
"avg_heart_rate": 72.5,
"total_sleep_hours": 210.5,
"avg_calories": 2345.6
}
@router.get("/activities/list", response_model=List[ActivityResponse])
async def list_activities(
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0)
):
# This would return metadata for all downloaded/available activities
# Implementation will connect with the services layer
return []
@router.get("/activities/query", response_model=List[ActivityResponse])
async def query_activities(
activity_type: Optional[str] = Query(None),
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
download_status: Optional[str] = Query(None)
):
# This would allow advanced filtering of activities
# Implementation will connect with the services layer
return []

View File

@@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
import traceback
from ..services.postgresql_manager import PostgreSQLManager
from ..utils.config import config
from ..services.garmin.client import GarminClient
router = APIRouter()
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
class GarminCredentials(BaseModel):
username: str
password: str
is_china: bool = False
class FitbitCredentials(BaseModel):
client_id: str
client_secret: str
class FitbitCallback(BaseModel):
callback_url: str
class GarminMFARequest(BaseModel):
verification_code: str
session_id: str
class AuthStatusResponse(BaseModel):
garmin: Optional[dict] = None
fitbit: Optional[dict] = None
@router.get("/setup/auth-status", response_model=AuthStatusResponse)
async def get_auth_status(db: Session = Depends(get_db)):
# This would return the current authentication status from the database
# Implementation will connect with the services layer
# For now, return placeholder until we have full implementation
return AuthStatusResponse(
garmin={
"username": "example@example.com",
"authenticated": False,
"token_expires_at": None,
"last_login": None,
"is_china": False
},
fitbit={
"client_id": "example_client_id",
"authenticated": False,
"token_expires_at": None,
"last_login": None
}
)
@router.post("/setup/garmin")
async def save_garmin_credentials(credentials: GarminCredentials, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
# This would save the Garmin credentials and attempt login
# Implementation will connect with the services layer
logger.info(f"Received Garmin credentials for user: {credentials.username}, is_china: {credentials.is_china}")
# Create the client with credentials but don't trigger login in __init__ if we handle it separately
garmin_client = GarminClient(credentials.username, credentials.password, credentials.is_china)
logger.debug("GarminClient instance created successfully")
try:
logger.debug("Attempting to log in to Garmin")
garmin_client.login()
# If login is successful, we're done
logger.info(f"Successfully authenticated Garmin user: {credentials.username}")
return {"status": "success", "message": "Garmin credentials saved and authenticated successfully"}
except Exception as e:
logger.error(f"Error during Garmin authentication: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
logger.error(f"Exception details: {repr(e)}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
if "MFA" in str(e) or "mfa" in str(e).lower() or "MFA Required" in str(e):
logger.info("MFA required for Garmin authentication")
# Initiate MFA process and get session ID
session_id = garmin_client.initiate_mfa(credentials.username)
return {"status": "mfa_required", "message": "Multi-factor authentication required", "session_id": session_id}
else:
logger.error(f"Authentication failed with error: {str(e)}")
return {"status": "error", "message": f"Error during authentication: {str(e)}"}
@router.post("/setup/garmin/mfa")
async def complete_garmin_mfa(mfa_request: GarminMFARequest, db: Session = Depends(get_db)):
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
# Complete the MFA process for Garmin using session ID
logger.info(f"Received MFA verification code for session {mfa_request.session_id}: {'*' * len(mfa_request.verification_code)}")
try:
# Create a basic Garmin client without credentials - we'll use the session data
garmin_client = GarminClient()
logger.debug(f"Attempting to handle MFA for session: {mfa_request.session_id}")
# Call the handle_mfa method which will use database-stored session data
success = garmin_client.handle_mfa(mfa_request.verification_code, session_id=mfa_request.session_id)
if success:
logger.info(f"MFA verification completed successfully for session: {mfa_request.session_id}")
return {"status": "success", "message": "MFA verification completed successfully"}
else:
logger.error(f"MFA verification failed for session: {mfa_request.session_id}")
return {"status": "error", "message": "MFA verification failed"}
except Exception as e:
logger.error(f"MFA verification failed for session {mfa_request.session_id} with exception: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
logger.error(f"Exception details: {repr(e)}")
import traceback
logger.error(f"Full traceback: {traceback.format_exc()}")
return {"status": "error", "message": f"MFA verification failed: {str(e)}"}
@router.post("/setup/fitbit")
async def save_fitbit_credentials(credentials: FitbitCredentials, db: Session = Depends(get_db)):
# This would save the Fitbit credentials and return auth URL
# Implementation will connect with the services layer
return {
"status": "success",
"auth_url": "https://www.fitbit.com/oauth2/authorize?...",
"message": "Fitbit credentials saved, please visit auth_url to authorize"
}
@router.post("/setup/fitbit/callback")
async def fitbit_callback(callback_data: FitbitCallback, db: Session = Depends(get_db)):
# This would handle the Fitbit OAuth callback
# Implementation will connect with the services layer
return {"status": "success", "message": "Fitbit OAuth flow completed successfully"}

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import List, Optional
router = APIRouter()
class SyncLogResponse(BaseModel):
id: int
operation: str
status: str
message: Optional[str]
start_time: str
end_time: Optional[str]
records_processed: int
records_failed: int
class StatusResponse(BaseModel):
total_weight_records: int
synced_weight_records: int
unsynced_weight_records: int
total_activities: int
downloaded_activities: int
recent_logs: List[SyncLogResponse]
@router.get("/status")
async def get_status():
# This would return the current sync status
# Implementation will connect with the services layer
return {
"total_weight_records": 100,
"synced_weight_records": 85,
"unsynced_weight_records": 15,
"total_activities": 50,
"downloaded_activities": 30,
"recent_logs": []
}

View File

@@ -0,0 +1,51 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from typing import Optional
from ..services.postgresql_manager import PostgreSQLManager
from sqlalchemy.orm import Session
from ..utils.config import config
router = APIRouter()
class SyncActivityRequest(BaseModel):
days_back: int = 30
class SyncResponse(BaseModel):
status: str
message: str
job_id: Optional[str] = None
def get_db():
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
yield session
@router.post("/sync/weight", response_model=SyncResponse)
async def sync_weight(db: Session = Depends(get_db)):
# This would trigger the weight sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Weight sync process started",
"job_id": "weight-sync-12345"
}
@router.post("/sync/activities", response_model=SyncResponse)
async def sync_activities(request: SyncActivityRequest, db: Session = Depends(get_db)):
# This would trigger the activity sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Activity sync process started",
"job_id": f"activity-sync-{request.days_back}"
}
@router.post("/sync/metrics", response_model=SyncResponse)
async def sync_metrics(db: Session = Depends(get_db)):
# This would trigger the health metrics sync process
# Implementation will connect with the services layer
return {
"status": "started",
"message": "Health metrics sync process started",
"job_id": "metrics-sync-12345"
}

View File

@@ -0,0 +1,13 @@
from sqlalchemy.ext.declarative import declarative_base
# Create a base class for all models to inherit from
Base = declarative_base()
# Import all models here to ensure they're registered with the Base
from .config import Configuration
from .api_token import APIToken
from .auth_status import AuthStatus
from .weight_record import WeightRecord
from .activity import Activity
from .health_metric import HealthMetric
from .sync_log import SyncLog

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, Text, LargeBinary
from sqlalchemy.sql import func
from ..models import Base
class Activity(Base):
__tablename__ = "activities"
id = Column(Integer, primary_key=True, index=True)
garmin_activity_id = Column(String, unique=True, nullable=False) # Original Garmin ID
activity_name = Column(String, nullable=True) # Name of the activity
activity_type = Column(String, nullable=True) # Type of activity (e.g., 'running', 'cycling')
start_time = Column(DateTime, nullable=True) # Start time of the activity
duration = Column(Integer, nullable=True) # Duration in seconds
file_content = Column(LargeBinary, nullable=True) # Activity file content stored in database (base64 encoded)
file_type = Column(String, nullable=True) # File type (.fit, .gpx, .tcx, etc.)
download_status = Column(String, default='pending') # 'pending', 'downloaded', 'failed'
downloaded_at = Column(DateTime, nullable=True) # When downloaded
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,23 @@
from sqlalchemy import Column, Integer, String, DateTime
from sqlalchemy.sql import func
from ..models import Base
import json
class APIToken(Base):
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
token_type = Column(String, nullable=False) # 'fitbit' or 'garmin'
access_token = Column(String, nullable=False) # This should be encrypted in production
refresh_token = Column(String, nullable=True) # This should be encrypted in production
expires_at = Column(DateTime, nullable=False)
scopes = Column(String, nullable=True)
garth_oauth1_token = Column(String, nullable=True) # OAuth1 token for garmin (JSON)
garth_oauth2_token = Column(String, nullable=True) # OAuth2 token for garmin (JSON)
# MFA session fields for garmin
mfa_session_id = Column(String, nullable=True)
mfa_resume_data = Column(String, nullable=True) # JSON blob
mfa_expires_at = Column(DateTime, nullable=True)
last_used = Column(DateTime, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean
from sqlalchemy.sql import func
from ..models import Base
class AuthStatus(Base):
__tablename__ = "auth_status"
id = Column(Integer, primary_key=True, index=True)
service_type = Column(String, nullable=False) # 'fitbit' or 'garmin'
username = Column(String, nullable=True) # Masked username for security display
authenticated = Column(Boolean, default=False) # Whether currently authenticated
token_expires_at = Column(DateTime, nullable=True) # When current token expires
last_login = Column(DateTime, nullable=True) # Last successful login
is_china = Column(Boolean, default=False) # For Garmin - whether using garmin.cn domain
last_check = Column(DateTime, nullable=True) # When status was last checked
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON
from sqlalchemy.sql import func
from datetime import datetime
from ..models import Base
class Configuration(Base):
__tablename__ = "configurations"
id = Column(Integer, primary_key=True, index=True)
fitbit_client_id = Column(String, nullable=True)
fitbit_client_secret = Column(String, nullable=True) # This should be encrypted in production
garmin_username = Column(String, nullable=True)
garmin_password = Column(String, nullable=True) # This should be encrypted in production
sync_settings = Column(JSON, nullable=True) # JSON field for sync preferences
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Float, Text
from sqlalchemy.sql import func
from ..models import Base
class HealthMetric(Base):
__tablename__ = "health_metrics"
id = Column(Integer, primary_key=True, index=True)
metric_type = Column(String, nullable=False) # Type of metric (e.g., 'steps', 'heart_rate')
metric_value = Column(Float, nullable=False) # Value of the metric
unit = Column(String, nullable=True) # Unit of measurement
timestamp = Column(DateTime, nullable=False) # When the metric was recorded
date = Column(DateTime, nullable=False) # Date of the metric
source = Column(String, nullable=False) # Source of the metric ('garmin')
detailed_data = Column(Text, nullable=True) # Additional details (stored as JSON string)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,18 @@
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func
from ..models import Base
class SyncLog(Base):
__tablename__ = "sync_logs"
id = Column(Integer, primary_key=True, index=True)
operation = Column(String, nullable=False) # 'weight_sync', 'activity_archive', 'metrics_download'
status = Column(String, nullable=False) # 'started', 'in_progress', 'completed', 'failed'
message = Column(Text, nullable=True) # Status message or error details
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=True) # When operation completed
records_processed = Column(Integer, default=0) # Number of records processed
records_failed = Column(Integer, default=0) # Number of records failed
user_id = Column(Integer, nullable=True) # Reference to user (if applicable)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,17 @@
from sqlalchemy import Column, Integer, String, DateTime, Float
from sqlalchemy.sql import func
from ..models import Base
class WeightRecord(Base):
__tablename__ = "weight_records"
id = Column(Integer, primary_key=True, index=True)
fitbit_id = Column(String, unique=True, nullable=False) # Original Fitbit ID to prevent duplicates
weight = Column(Float, nullable=False) # Weight value
unit = Column(String, nullable=False) # Unit (e.g., 'kg', 'lbs')
date = Column(DateTime, nullable=False) # Date of measurement
timestamp = Column(DateTime, nullable=False) # Exact timestamp
sync_status = Column(String, default='unsynced') # 'unsynced', 'synced', 'failed'
garmin_id = Column(String, nullable=True) # ID of record if synced to Garmin
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,74 @@
import fitbit
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
import logging
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):
self.client_id = client_id
self.client_secret = client_secret
self.access_token = access_token
self.refresh_token = refresh_token
self.fitbit_client = None
if access_token and refresh_token:
self.fitbit_client = fitbit.Fitbit(
client_id=client_id,
client_secret=client_secret,
access_token=access_token,
refresh_token=refresh_token,
# Callback for token refresh if needed
)
def get_authorization_url(self, redirect_uri: str) -> str:
"""Generate authorization URL for Fitbit OAuth flow."""
# This would generate the Fitbit authorization URL
auth_url = f"https://www.fitbit.com/oauth2/authorize?response_type=code&client_id={self.client_id}&redirect_uri={redirect_uri}&scope=weight"
logger.info(f"Generated Fitbit authorization URL: {auth_url}")
return auth_url
def exchange_code_for_token(self, code: str, redirect_uri: str) -> Dict[str, str]:
"""Exchange authorization code for access and refresh tokens."""
# This would exchange the authorization code for tokens
# Implementation would use the Fitbit library to exchange the code
logger.info(f"Exchanging authorization code for tokens")
# Return mock response for now
return {
"access_token": "mock_access_token",
"refresh_token": "mock_refresh_token",
"expires_at": (datetime.now() + timedelta(hours=1)).isoformat()
}
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_client:
raise Exception("Fitbit client not authenticated")
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
try:
# Get weight logs from Fitbit
weight_logs = self.fitbit_client.get_bodyweight(
base_date=start_date,
end_date=end_date
)
logger.info(f"Fetched {len(weight_logs.get('weight', []))} weight entries from Fitbit")
return weight_logs.get('weight', [])
except Exception as e:
logger.error(f"Error fetching weight logs from Fitbit: {str(e)}")
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()
}

View File

@@ -0,0 +1,217 @@
import garth
import garminconnect
from datetime import datetime, timedelta
from uuid import uuid4
import json
import traceback
from src.utils.helpers import setup_logger
from src.models.api_token import APIToken
from src.services.postgresql_manager import PostgreSQLManager
from src.utils.config import config
logger = setup_logger(__name__)
class AuthMixin:
def login(self):
"""Login to Garmin Connect with proper token handling."""
logger.info(f"Starting login process for Garmin user: {self.username}")
try:
logger.debug(f"Attempting garth login for user: {self.username}")
garth.login(self.username, self.password, return_on_mfa=True)
logger.debug(f"Successfully completed garth authentication for: {self.username}")
logger.debug(f"Creating Garmin Connect client for user: {self.username}")
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
logger.debug(f"Successfully created Garmin Connect client for user: {self.username}")
self.is_connected = True
logger.info(f"Setting is_connected to True for user: {self.username}")
self.save_tokens()
logger.info(f"Successfully logged in to Garmin Connect as {self.username}")
except Exception as e:
logger.error(f"Error logging in to Garmin Connect: {str(e)}")
logger.error(f"Exception type: {type(e).__name__}")
error_str = str(e).lower()
if "mfa" in error_str or "2fa" in error_str or "unauthorized" in error_str:
logger.warning(f"Multi-factor authentication likely required for {self.username}")
logger.debug(f"Detected MFA indicator in error message: {error_str}")
raise Exception("MFA Required: Please provide verification code")
logger.error(f"Full traceback: {traceback.format_exc()}")
raise e
def save_tokens(self):
"""Save garth tokens to be used later."""
logger.info(f"Starting token saving process for user: {self.username}")
try:
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
oauth1_token = getattr(garth.client, 'oauth1_token', None)
oauth2_token = getattr(garth.client, 'oauth2_token', None)
if oauth1_token:
try:
token_dict = oauth1_token.__dict__ if hasattr(oauth1_token, '__dict__') else str(oauth1_token)
token_record.garth_oauth1_token = json.dumps(token_dict, default=str)
except Exception as e:
logger.warning(f"Could not serialize OAuth1 token for user {self.username}: {e}")
if oauth2_token:
try:
token_dict = oauth2_token.__dict__ if hasattr(oauth2_token, '__dict__') else str(oauth2_token)
token_record.garth_oauth2_token = json.dumps(token_dict, default=str)
except Exception as e:
logger.warning(f"Could not serialize OAuth2 token for user {self.username}: {e}")
session.commit()
logger.info(f"Garmin tokens saved successfully for user: {self.username}")
except Exception as e:
logger.error(f"Error saving garth tokens for user {self.username}: {str(e)}")
raise e
def load_tokens(self):
"""Load garth tokens to resume a session."""
logger.info(f"Starting token loading process for user: {self.username}")
try:
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
try:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
except Exception as db_error:
logger.info(f"No existing Garmin tokens found for user {self.username} or table doesn't exist: {db_error}")
return False
if not token_record or (not token_record.garth_oauth1_token and not token_record.garth_oauth2_token):
logger.info(f"No Garmin token record found in database for user: {self.username}")
return False
if token_record.garth_oauth1_token:
try:
oauth1_data = json.loads(token_record.garth_oauth1_token)
setattr(garth.client, 'oauth1_token', oauth1_data)
logger.info(f"Successfully restored OAuth1 token for user: {self.username}")
except Exception as e:
logger.warning(f"Could not restore OAuth1 token for user {self.username}: {e}")
if token_record.garth_oauth2_token:
try:
oauth2_data = json.loads(token_record.garth_oauth2_token)
setattr(garth.client, 'oauth2_token', oauth2_data)
logger.info(f"Successfully restored OAuth2 token for user: {self.username}")
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
self.is_connected = True
logger.debug(f"Successfully created Garmin Connect client for user {self.username} with restored session")
return True
except Exception as e:
logger.warning(f"Could not restore OAuth2 token for user {self.username}: {e}")
return True
except Exception as e:
logger.error(f"Error loading garth tokens for user {self.username}: {str(e)}")
return False
def initiate_mfa(self, username: str = None):
"""Initiate the MFA process and return session data."""
user_identifier = username if username else self.username
logger.info(f"Initiating MFA process for Garmin user: {user_identifier}")
mfa_session_id = str(uuid4())
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(APIToken.token_type == 'garmin').first()
if not token_record:
token_record = APIToken(token_type='garmin')
session.add(token_record)
token_record.mfa_session_id = mfa_session_id
resume_data = {
'username': user_identifier,
'password': self.password,
'is_china': self.is_china
}
token_record.mfa_resume_data = json.dumps(resume_data)
token_record.mfa_expires_at = datetime.now() + timedelta(minutes=10)
session.commit()
logger.info(f"MFA session initiated for user: {user_identifier}, session ID: {mfa_session_id}")
return mfa_session_id
def handle_mfa(self, verification_code: str, session_id: str = None):
"""Handle the MFA process by completing authentication with the verification code."""
logger.info(f"Starting MFA completion process with session ID: {session_id}")
db_manager = PostgreSQLManager(config.DATABASE_URL)
with db_manager.get_db_session() as session:
token_record = session.query(APIToken).filter(
APIToken.token_type == 'garmin',
APIToken.mfa_session_id == session_id
).first()
if not token_record:
raise Exception("No pending MFA authentication for this session.")
if token_record.mfa_expires_at and datetime.now() > token_record.mfa_expires_at:
self.cleanup_mfa_session(token_record, session)
raise Exception("MFA verification code has expired.")
try:
resume_data = json.loads(token_record.mfa_resume_data)
self.username = resume_data.get('username')
self.password = resume_data.get('password')
if resume_data.get('is_china', False):
garth.configure(domain="garmin.cn")
try:
garth.client.mfa_submit(verification_code)
except AttributeError:
garth.login(self.username, self.password, verification_code)
self.garmin_client = garminconnect.Garmin(self.username, self.password)
self.garmin_client.garth = garth.client
try:
profile = self.garmin_client.get_full_name()
logger.info(f"Verified authentication for user: {profile}")
except Exception as verify_error:
logger.warning(f"Could not verify authentication for user {self.username}: {verify_error}")
self.is_connected = True
self.save_tokens()
self.cleanup_mfa_session(token_record, session)
logger.info(f"Successfully completed MFA authentication for {self.username}")
return True
except Exception as e:
logger.error(f"Error during MFA completion for user {self.username}: {e}")
self.cleanup_mfa_session(token_record, session)
raise e
def cleanup_mfa_session(self, token_record, session):
"""Clear out MFA session data from the token record."""
token_record.mfa_session_id = None
token_record.mfa_resume_data = None
token_record.mfa_expires_at = None
session.commit()
logger.debug("MFA session data cleaned up.")

View File

@@ -0,0 +1,39 @@
import garth
from src.utils.helpers import setup_logger
from .auth import AuthMixin
from .data import DataMixin
logger = setup_logger(__name__)
class GarminClient(AuthMixin, DataMixin):
def __init__(self, username: str = None, password: str = None, is_china: bool = False):
self.username = username
self.password = password
self.is_china = is_china
self.garmin_client = None
self.is_connected = False
logger.debug(f"Initializing GarminClient for user: {username}, is_china: {is_china}")
if is_china:
logger.debug("Configuring garth for China domain")
garth.configure(domain="garmin.cn")
if username and password:
logger.info(f"Attempting to authenticate Garmin user: {username}")
if not self.load_tokens():
logger.info("No valid tokens found, attempting fresh login")
self.login()
else:
logger.info("Successfully loaded existing tokens, skipping fresh login")
else:
logger.debug("No username/password provided during initialization")
def check_connection(self) -> bool:
"""Check if the connection to Garmin is still valid."""
try:
profile = self.garmin_client.get_full_name() if self.garmin_client else None
return profile is not None
except:
self.is_connected = False
return False

View File

@@ -0,0 +1,139 @@
from datetime import datetime
from typing import List, Dict, Any, Optional
from src.utils.helpers import setup_logger
logger = setup_logger(__name__)
class DataMixin:
def upload_weight(self, weight: float, unit: str = 'kg', timestamp: datetime = None) -> bool:
"""Upload weight entry to Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not timestamp:
timestamp = datetime.now()
try:
result = self.garmin_client.add_body_composition(
timestamp=timestamp,
weight=weight
)
except Exception:
try:
result = self.garmin_client.add_body_composition(
timestamp=timestamp.isoformat(),
weight=weight
)
except Exception:
result = self.garmin_client.add_body_composition(
timestamp=timestamp.strftime('%Y-%m-%d'),
weight=weight
)
logger.info(f"Successfully uploaded weight: {weight} {unit} at {timestamp}")
return result is not None
except Exception as e:
logger.error(f"Error uploading weight to Garmin: {str(e)}")
if "401" in str(e) or "unauthorized" in str(e).lower():
logger.error("Authentication failed - need to re-authenticate")
raise Exception("Authentication expired, needs re-authentication")
raise e
def get_activities(self, start_date: str, end_date: str = None, limit: int = 100) -> List[Dict[str, Any]]:
"""Fetch activity list from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
activities = self.garmin_client.get_activities(start_date, end_date)
logger.info(f"Fetched {len(activities)} activities from Garmin")
return activities
except Exception as e:
logger.error(f"Error fetching activities from Garmin: {str(e)}")
raise e
def download_activity(self, activity_id: str, file_type: str = 'tcx') -> Optional[bytes]:
"""Download activity file from Garmin Connect and return its content."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
file_content = self.garmin_client.get_activity_details(activity_id)
logger.info(f"Downloaded activity {activity_id} as {file_type} format")
return file_content if file_content else b""
except Exception as e:
logger.error(f"Error downloading activity {activity_id} from Garmin: {str(e)}")
raise e
def get_heart_rates(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch heart rate data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
heart_rates = self.garmin_client.get_heart_rates(start_date, end_date)
logger.info(f"Fetched heart rate data from Garmin for {start_date} to {end_date}")
return heart_rates
except Exception as e:
logger.error(f"Error fetching heart rate data from Garmin: {str(e)}")
raise e
def get_sleep_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch sleep data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
sleep_data = self.garmin_client.get_sleep_data(start_date, end_date)
logger.info(f"Fetched sleep data from Garmin for {start_date} to {end_date}")
return sleep_data
except Exception as e:
logger.error(f"Error fetching sleep data from Garmin: {str(e)}")
raise e
def get_steps_data(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch steps data from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
steps_data = self.garmin_client.get_steps_data(start_date, end_date)
logger.info(f"Fetched steps data from Garmin for {start_date} to {end_date}")
return steps_data
except Exception as e:
logger.error(f"Error fetching steps data from Garmin: {str(e)}")
raise e
def get_all_metrics(self, start_date: str, end_date: str = None) -> Dict[str, Any]:
"""Fetch all available metrics from Garmin Connect."""
if not self.is_connected:
raise Exception("Not connected to Garmin Connect")
try:
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
metrics = {
'heart_rates': self.get_heart_rates(start_date, end_date),
'sleep_data': self.get_sleep_data(start_date, end_date),
'steps_data': self.get_steps_data(start_date, end_date),
}
logger.info(f"Fetched all metrics from Garmin for {start_date} to {end_date}")
return metrics
except Exception as e:
logger.error(f"Error fetching all metrics from Garmin: {str(e)}")
raise e

View File

@@ -0,0 +1,47 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
import os
from contextlib import contextmanager
# Create a base class for declarative models
Base = declarative_base()
class PostgreSQLManager:
def __init__(self, database_url: str = None):
if database_url is None:
database_url = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
self.engine = create_engine(
database_url,
poolclass=QueuePool,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=300,
)
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
def init_db(self):
"""Initialize the database by creating all tables."""
# Import all models to ensure they're registered with the Base
from ..models.config import Configuration
from ..models.api_token import APIToken
from ..models.auth_status import AuthStatus
from ..models.weight_record import WeightRecord
from ..models.activity import Activity
from ..models.health_metric import HealthMetric
from ..models.sync_log import SyncLog
# Create all tables
Base.metadata.create_all(bind=self.engine)
@contextmanager
def get_db_session(self):
"""Provide a database session."""
db = self.SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,322 @@
from ..models.weight_record import WeightRecord
from ..models.sync_log import SyncLog
from ..services.fitbit_client import FitbitClient
from ..services.garmin.client import GarminClient
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import Dict
import logging
from ..utils.helpers import setup_logger
logger = setup_logger(__name__)
class SyncApp:
def __init__(self, db_session: Session, fitbit_client: FitbitClient, garmin_client: GarminClient):
self.db_session = db_session
self.fitbit_client = fitbit_client
self.garmin_client = garmin_client
def sync_weight_data(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
"""Sync weight data from Fitbit to Garmin."""
if not start_date:
# Default to 1 year back
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="weight_sync",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch unsynced weight records from Fitbit
fitbit_weights = self.fitbit_client.get_weight_logs(start_date, end_date)
# Track processing results
processed_count = 0
failed_count = 0
for weight_entry in fitbit_weights:
try:
# Check if this weight entry already exists in our DB (prevents duplicates)
fitbit_id = weight_entry.get('logId', str(weight_entry.get('date', '') + str(weight_entry.get('weight', 0))))
existing_record = self.db_session.query(WeightRecord).filter(
WeightRecord.fitbit_id == fitbit_id
).first()
if existing_record and existing_record.sync_status == 'synced':
# Skip if already synced
continue
# Create or update weight record
if not existing_record:
weight_record = WeightRecord(
fitbit_id=fitbit_id,
weight=weight_entry.get('weight'),
unit=weight_entry.get('unit', 'kg'),
date=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
timestamp=datetime.fromisoformat(weight_entry.get('date')) if isinstance(weight_entry.get('date'), str) else weight_entry.get('date'),
sync_status='unsynced'
)
self.db_session.add(weight_record)
self.db_session.flush() # Get the ID
else:
weight_record = existing_record
# Upload to Garmin if not already synced
if weight_record.sync_status != 'synced':
# Upload weight to Garmin
success = self.garmin_client.upload_weight(
weight=weight_record.weight,
unit=weight_record.unit,
timestamp=weight_record.timestamp
)
if success:
weight_record.sync_status = 'synced'
weight_record.garmin_id = "garmin_" + fitbit_id # Placeholder for Garmin ID
else:
weight_record.sync_status = 'failed'
failed_count += 1
processed_count += 1
except Exception as e:
logger.error(f"Error processing weight entry: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Weight sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during weight sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e
def sync_activities(self, days_back: int = 30) -> Dict[str, int]:
"""Sync activity data from Garmin to local storage."""
start_date = (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d')
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="activity_archive",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch activities from Garmin
garmin_activities = self.garmin_client.get_activities(start_date, end_date)
processed_count = 0
failed_count = 0
from ..models.activity import Activity
for activity in garmin_activities:
try:
activity_id = str(activity.get('activityId', ''))
existing_activity = self.db_session.query(Activity).filter(
Activity.garmin_activity_id == activity_id
).first()
if existing_activity and existing_activity.download_status == 'downloaded':
# Skip if already downloaded
continue
# Create or update activity record
if not existing_activity:
activity_record = Activity(
garmin_activity_id=activity_id,
activity_name=activity.get('activityName', ''),
activity_type=activity.get('activityType', ''),
start_time=datetime.fromisoformat(activity.get('startTimeLocal', '')) if activity.get('startTimeLocal') else None,
duration=activity.get('duration', 0),
download_status='pending'
)
self.db_session.add(activity_record)
self.db_session.flush()
else:
activity_record = existing_activity
# Download activity file if not already downloaded
if activity_record.download_status != 'downloaded':
# Download in various formats
file_formats = ['tcx', 'gpx', 'fit']
downloaded_successfully = False
for fmt in file_formats:
try:
# Get file content from Garmin client
file_content = self.garmin_client.download_activity(activity_id, file_type=fmt)
if file_content:
# Store file content directly in the database
activity_record.file_content = file_content
activity_record.file_type = fmt
activity_record.download_status = 'downloaded'
activity_record.downloaded_at = datetime.now()
downloaded_successfully = True
break
except Exception as e:
logger.warning(f"Could not download activity {activity_id} in {fmt} format: {str(e)}")
continue
if not downloaded_successfully:
activity_record.download_status = 'failed'
failed_count += 1
processed_count += 1
except Exception as e:
logger.error(f"Error processing activity {activity.get('activityId', '')}: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Activity sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during activity sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e
def sync_health_metrics(self, start_date: str = None, end_date: str = None) -> Dict[str, int]:
"""Sync health metrics from Garmin to local database."""
if not start_date:
# Default to 1 year back
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
if not end_date:
end_date = datetime.now().strftime('%Y-%m-%d')
# Create a sync log entry
sync_log = SyncLog(
operation="metrics_download",
status="started",
start_time=datetime.now(),
records_processed=0,
records_failed=0
)
self.db_session.add(sync_log)
self.db_session.commit()
try:
# Fetch all metrics from Garmin
all_metrics = self.garmin_client.get_all_metrics(start_date, end_date)
processed_count = 0
failed_count = 0
from ..models.health_metric import HealthMetric
# Process heart rate data
heart_rates = all_metrics.get('heart_rates', {})
if 'heartRateValues' in heart_rates:
for hr_data in heart_rates['heartRateValues']:
try:
timestamp = datetime.fromisoformat(hr_data[0]) if isinstance(hr_data[0], str) else datetime.fromtimestamp(hr_data[0]/1000)
metric = HealthMetric(
metric_type='heart_rate',
metric_value=hr_data[1],
unit='bpm',
timestamp=timestamp,
date=timestamp.date(),
source='garmin',
detailed_data=None
)
self.db_session.add(metric)
processed_count += 1
except Exception as e:
logger.error(f"Error processing heart rate data: {str(e)}")
failed_count += 1
# Process other metrics similarly...
# For brevity, I'll show just one more example
sleep_data = all_metrics.get('sleep_data', {})
sleep_levels = sleep_data.get('sleep', [])
for sleep_entry in sleep_levels:
try:
metric = HealthMetric(
metric_type='sleep',
metric_value=sleep_entry.get('duration', 0),
unit='minutes',
timestamp=datetime.now(), # Actual timestamp would come from data
date=datetime.now().date(), # Actual date would come from data
source='garmin',
detailed_data=sleep_entry
)
self.db_session.add(metric)
processed_count += 1
except Exception as e:
logger.error(f"Error processing sleep data: {str(e)}")
failed_count += 1
# Update sync log with results
sync_log.status = "completed" if failed_count == 0 else "completed_with_errors"
sync_log.end_time = datetime.now()
sync_log.records_processed = processed_count
sync_log.records_failed = failed_count
self.db_session.commit()
logger.info(f"Health metrics sync completed: {processed_count} processed, {failed_count} failed")
return {
"processed": processed_count,
"failed": failed_count
}
except Exception as e:
logger.error(f"Error during health metrics sync: {str(e)}")
# Update sync log with error status
sync_log.status = "failed"
sync_log.end_time = datetime.now()
sync_log.message = str(e)
self.db_session.commit()
raise e

View File

@@ -0,0 +1,37 @@
import os
from typing import Optional
from dataclasses import dataclass
@dataclass
class Config:
"""Configuration class to store environment variables."""
# Database
DATABASE_URL: str = os.getenv("DATABASE_URL", "postgresql://postgres:password@localhost:5432/fitbit_garmin_sync")
# Fitbit
FITBIT_CLIENT_ID: Optional[str] = os.getenv("FITBIT_CLIENT_ID")
FITBIT_CLIENT_SECRET: Optional[str] = os.getenv("FITBIT_CLIENT_SECRET")
FITBIT_REDIRECT_URI: str = os.getenv("FITBIT_REDIRECT_URI", "http://localhost:8000/api/setup/fitbit/callback")
# Garmin
GARMIN_USERNAME: Optional[str] = os.getenv("GARMIN_USERNAME")
GARMIN_PASSWORD: Optional[str] = os.getenv("GARMIN_PASSWORD")
# Server
HOST: str = os.getenv("HOST", "0.0.0.0")
PORT: int = int(os.getenv("PORT", "8000"))
# App
DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true"
def validate(self) -> bool:
"""Validate that required environment variables are set."""
required_vars = ['DATABASE_URL']
for var in required_vars:
if not getattr(self, var):
raise ValueError(f"Missing required environment variable: {var}")
return True
# Create a global config instance
config = Config()

View File

@@ -0,0 +1,36 @@
import logging
from datetime import datetime
from typing import Optional
import os
def setup_logger(name: str, level=logging.DEBUG):
"""Function to setup a logger that writes to the console."""
formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s')
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger = logging.getLogger(name)
logger.setLevel(level)
if not logger.handlers:
logger.addHandler(console_handler)
return logger
def get_current_timestamp() -> str:
"""Get current timestamp in ISO format."""
return datetime.utcnow().isoformat()
def validate_environment_vars(required_vars: list) -> bool:
"""Validate that required environment variables are set."""
missing_vars = []
for var in required_vars:
if not os.getenv(var):
missing_vars.append(var)
if missing_vars:
print(f"Missing required environment variables: {', '.join(missing_vars)}")
return False
return True

View File

@@ -0,0 +1,308 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync Dashboard</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync Dashboard</h1>
<div class="row mb-4">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Weight Records</h5>
<p class="card-text">Total: <span id="total-weights">0</span></p>
<p class="card-text">Synced: <span id="synced-weights">0</span></p>
<p class="card-text">Unsynced: <span id="unsynced-weights">0</span></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Activities</h5>
<p class="card-text">Total: <span id="total-activities">0</span></p>
<p class="card-text">Downloaded: <span id="downloaded-activities">0</span></p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">Sync Status</h5>
<div class="d-grid gap-2">
<button class="btn btn-primary" type="button" id="sync-weight-btn">Sync Weight</button>
<button class="btn btn-secondary" type="button" id="sync-activities-btn">Sync Activities</button>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Recent Sync Logs</h3>
<div class="table-responsive">
<table class="table table-striped" id="sync-logs-table">
<thead>
<tr>
<th>Operation</th>
<th>Status</th>
<th>Start Time</th>
<th>Records Processed</th>
<th>Records Failed</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5">Loading logs...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Health Metrics</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Activity Files</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<a href="/setup" class="btn btn-primary me-md-2">Setup & Configuration</a>
<a href="/docs" class="btn btn-outline-secondary" target="_blank">API Documentation</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Health Metrics</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-info me-md-2" type="button" id="sync-metrics-btn">Sync Health Metrics</button>
<button class="btn btn-outline-info me-md-2" type="button" id="view-metrics-btn">View Health Data Summary</button>
<button class="btn btn-outline-info" type="button" id="query-metrics-btn">Query Metrics</button>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-5">
<div class="col-md-12">
<h3>Activity Files</h3>
<div class="card">
<div class="card-body">
<div class="d-grid gap-2 d-md-flex justify-content-md-start">
<button class="btn btn-outline-secondary me-md-2" type="button" id="list-activities-btn">List Stored Activities</button>
<button class="btn btn-outline-secondary" type="button" id="download-activities-btn">Download Activity File</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Load dashboard data when page loads
document.addEventListener('DOMContentLoaded', function() {
loadDashboardData();
// Set up sync buttons
document.getElementById('sync-weight-btn').addEventListener('click', syncWeight);
document.getElementById('sync-activities-btn').addEventListener('click', syncActivities);
// Set up metrics buttons
document.getElementById('sync-metrics-btn').addEventListener('click', syncHealthMetrics);
document.getElementById('view-metrics-btn').addEventListener('click', viewHealthSummary);
document.getElementById('query-metrics-btn').addEventListener('click', queryMetrics);
// Set up activity file buttons
document.getElementById('list-activities-btn').addEventListener('click', listActivities);
document.getElementById('download-activities-btn').addEventListener('click', downloadActivityFile);
});
async function loadDashboardData() {
try {
const response = await fetch('/api/status');
const data = await response.json();
document.getElementById('total-weights').textContent = data.total_weight_records;
document.getElementById('synced-weights').textContent = data.synced_weight_records;
document.getElementById('unsynced-weights').textContent = data.unsynced_weight_records;
document.getElementById('total-activities').textContent = data.total_activities;
document.getElementById('downloaded-activities').textContent = data.downloaded_activities;
// Update logs table
const logsBody = document.querySelector('#sync-logs-table tbody');
logsBody.innerHTML = '';
data.recent_logs.forEach(log => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${log.operation}</td>
<td>${log.status}</td>
<td>${log.start_time}</td>
<td>${log.records_processed}</td>
<td>${log.records_failed}</td>
`;
logsBody.appendChild(row);
});
} catch (error) {
console.error('Error loading dashboard data:', error);
}
}
async function syncWeight() {
try {
const response = await fetch('/api/sync/weight', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
alert(`Weight sync initiated: ${data.message}`);
// Refresh dashboard data
loadDashboardData();
} catch (error) {
console.error('Error syncing weight:', error);
alert('Error initiating weight sync: ' + error.message);
}
}
async function syncActivities() {
try {
const response = await fetch('/api/sync/activities', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ days_back: 30 })
});
const data = await response.json();
alert(`Activity sync initiated: ${data.message}`);
// Refresh dashboard data
loadDashboardData();
} catch (error) {
console.error('Error syncing activities:', error);
alert('Error initiating activity sync: ' + error.message);
}
}
async function syncHealthMetrics() {
try {
const response = await fetch('/api/sync/metrics', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
alert(`Health metrics sync initiated: ${data.message}`);
} catch (error) {
console.error('Error syncing health metrics:', error);
alert('Error initiating health metrics sync: ' + error.message);
}
}
async function viewHealthSummary() {
try {
const response = await fetch('/api/health-data/summary');
const data = await response.json();
alert(`Health Summary:
Steps: ${data.total_steps || 0}
Avg Heart Rate: ${data.avg_heart_rate || 0}
Sleep Hours: ${data.total_sleep_hours || 0}
Avg Calories: ${data.avg_calories || 0}`);
} catch (error) {
console.error('Error fetching health summary:', error);
alert('Error fetching health summary: ' + error.message);
}
}
async function queryMetrics() {
try {
const response = await fetch('/api/metrics/query');
const data = await response.json();
alert(`Found ${data.length} health metrics`);
} catch (error) {
console.error('Error querying metrics:', error);
alert('Error querying metrics: ' + error.message);
}
}
async function listActivities() {
try {
const response = await fetch('/api/activities/list');
const data = await response.json();
alert(`Found ${data.length} stored activities`);
} catch (error) {
console.error('Error listing activities:', error);
alert('Error listing activities: ' + error.message);
}
}
async function downloadActivityFile() {
try {
// For demo purposes, we'll use a placeholder ID
// In a real implementation, this would prompt for activity ID or list available activities
const activityId = prompt('Enter activity ID to download:', '12345');
if (activityId) {
// This would initiate a download of the stored activity file
window.open(`/api/activities/download/${activityId}`, '_blank');
}
} catch (error) {
console.error('Error downloading activity file:', error);
alert('Error downloading activity file: ' + error.message);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html>
<head>
<title>Fitbit-Garmin Sync - Setup</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>Fitbit-Garmin Sync - Setup</h1>
<!-- Current Status Section -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Current Status</h5>
<div id="status-info">
<p>Loading status...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Garmin Connect Credentials</h5>
<form id="garmin-credentials-form">
<div class="mb-3">
<label for="garmin-username" class="form-label">Username</label>
<input type="text" class="form-control" id="garmin-username" name="username" required>
</div>
<div class="mb-3">
<label for="garmin-password" class="form-label">Password</label>
<input type="password" class="form-control" id="garmin-password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="garmin-china" name="is_china">
<label class="form-check-label" for="garmin-china">Use China domain (garmin.cn)</label>
</div>
<button type="submit" class="btn btn-primary">Save Garmin Credentials</button>
</form>
<!-- Garmin Authentication Status -->
<div id="garmin-auth-status" class="mt-3">
<p>Loading Garmin authentication status...</p>
</div>
<!-- MFA Section -->
<div id="garmin-mfa-section" class="mt-3" style="display: none;">
<h6>Multi-Factor Authentication (MFA)</h6>
<div class="mb-3">
<label for="mfa-code" class="form-label">Enter Verification Code</label>
<input type="text" class="form-control" id="mfa-code" placeholder="Enter code from your authenticator app or SMS">
</div>
<button type="button" class="btn btn-primary" id="submit-mfa-btn">Submit Verification Code</button>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Fitbit API Credentials</h5>
<form id="fitbit-credentials-form">
<div class="mb-3">
<label for="fitbit-client-id" class="form-label">Client ID</label>
<input type="text" class="form-control" id="fitbit-client-id" name="client_id" required>
</div>
<div class="mb-3">
<label for="fitbit-client-secret" class="form-label">Client Secret</label>
<input type="password" class="form-control" id="fitbit-client-secret" name="client_secret" required>
</div>
<button type="submit" class="btn btn-primary">Save Fitbit Credentials</button>
</form>
<div class="mt-3">
<div id="auth-url-container" style="display: none;">
<p>After saving credentials, click the link below to authorize:</p>
<a id="auth-link" class="btn btn-secondary" href="#" target="_blank">Authorize with Fitbit</a>
</div>
</div>
<!-- Fitbit Authentication Status -->
<div id="fitbit-auth-status" class="mt-3">
<p>Loading Fitbit authentication status...</p>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5 class="card-title">Complete Fitbit OAuth Flow</h5>
<form id="fitbit-callback-form">
<div class="mb-3">
<label for="callback-url" class="form-label">Paste full callback URL from browser</label>
<input type="url" class="form-control" id="callback-url" name="callback_url" required>
</div>
<button type="submit" class="btn btn-success">Complete OAuth Flow</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Load initial status information
loadStatusInfo();
// Setup form event listeners
document.getElementById('garmin-credentials-form').addEventListener('submit', saveGarminCredentials);
document.getElementById('fitbit-credentials-form').addEventListener('submit', saveFitbitCredentials);
document.getElementById('fitbit-callback-form').addEventListener('submit', completeFitbitAuth);
});
async function loadStatusInfo() {
try {
// Get general status
const statusResponse = await fetch('/api/status');
const statusData = await statusResponse.json();
// Update status info
const statusContainer = document.getElementById('status-info');
statusContainer.innerHTML = `
<div class="row">
<div class="col-md-4">
<p><strong>Total Weight Records:</strong> ${statusData.total_weight_records}</p>
<p><strong>Synced to Garmin:</strong> ${statusData.synced_weight_records}</p>
</div>
<div class="col-md-4">
<p><strong>Unsynced Records:</strong> ${statusData.unsynced_weight_records}</p>
<p><strong>Total Activities:</strong> ${statusData.total_activities}</p>
</div>
<div class="col-md-4">
<p><strong>Downloaded Activities:</strong> ${statusData.downloaded_activities}</p>
</div>
</div>
`;
// Get authentication status from a new API endpoint
const authStatusResponse = await fetch('/api/setup/auth-status');
if (authStatusResponse.ok) {
const authData = await authStatusResponse.json();
// Update Garmin auth status
const garminStatusContainer = document.getElementById('garmin-auth-status');
if (authData.garmin) {
garminStatusContainer.innerHTML = `
<div class="alert ${authData.garmin.authenticated ? 'alert-success' : 'alert-warning'}">
<h6>Garmin Authentication Status</h6>
<p><strong>Username:</strong> ${authData.garmin.username || 'Not set'}</p>
<p><strong>Authenticated:</strong> ${authData.garmin.authenticated ? 'Yes' : 'No'}</p>
${authData.garmin.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.garmin.token_expires_at).toLocaleString()}</p>` : ''}
${authData.garmin.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.garmin.last_login).toLocaleString()}</p>` : ''}
<p><strong>Domain:</strong> ${authData.garmin.is_china ? 'garmin.cn' : 'garmin.com'}</p>
</div>
`;
}
// Update Fitbit auth status
const fitbitStatusContainer = document.getElementById('fitbit-auth-status');
if (authData.fitbit) {
fitbitStatusContainer.innerHTML = `
<div class="alert ${authData.fitbit.authenticated ? 'alert-success' : 'alert-warning'}">
<h6>Fitbit Authentication Status</h6>
<p><strong>Client ID:</strong> ${authData.fitbit.client_id ? authData.fitbit.client_id.substring(0, 10) + '...' : 'Not set'}</p>
<p><strong>Authenticated:</strong> ${authData.fitbit.authenticated ? 'Yes' : 'No'}</p>
${authData.fitbit.token_expires_at ? `<p><strong>Token Expires:</strong> ${new Date(authData.fitbit.token_expires_at).toLocaleString()}</p>` : ''}
${authData.fitbit.last_login ? `<p><strong>Last Login:</strong> ${new Date(authData.fitbit.last_login).toLocaleString()}</p>` : ''}
</div>
`;
}
}
} catch (error) {
console.error('Error loading status info:', error);
}
}
async function saveGarminCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
username: formData.get('username'),
password: formData.get('password'),
is_china: formData.get('is_china') === 'on' || formData.get('is_china') === 'true'
};
try {
const response = await fetch('/api/setup/garmin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
if (data.status === 'mfa_required') {
// Show MFA section if MFA is required and store session ID
document.getElementById('garmin-mfa-section').style.display = 'block';
// Store the session ID for later use when submitting the MFA code
window.garmin_mfa_session_id = data.session_id;
alert('Multi-factor authentication required. Please enter the verification code sent to your device.');
} else {
alert(data.message || 'Garmin credentials saved successfully');
// Refresh status after saving
loadStatusInfo();
// Hide MFA section if showing
document.getElementById('garmin-mfa-section').style.display = 'none';
}
} catch (error) {
console.error('Error saving Garmin credentials:', error);
alert('Error saving Garmin credentials: ' + error.message);
}
}
async function saveFitbitCredentials(event) {
event.preventDefault();
const formData = new FormData(event.target);
const credentials = {
client_id: formData.get('client_id'),
client_secret: formData.get('client_secret')
};
try {
const response = await fetch('/api/setup/fitbit', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
});
const data = await response.json();
alert(data.message || 'Fitbit credentials saved successfully');
// Show the authorization link
const authLink = document.getElementById('auth-link');
authLink.href = data.auth_url;
document.getElementById('auth-url-container').style.display = 'block';
} catch (error) {
console.error('Error saving Fitbit credentials:', error);
alert('Error saving Fitbit credentials: ' + error.message);
}
}
async function completeFitbitAuth(event) {
event.preventDefault();
const formData = new FormData(event.target);
const callbackData = {
callback_url: formData.get('callback_url')
};
try {
const response = await fetch('/api/setup/fitbit/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(callbackData)
});
const data = await response.json();
alert(data.message || 'Fitbit OAuth flow completed successfully');
// Refresh status after completing OAuth
loadStatusInfo();
} catch (error) {
console.error('Error completing Fitbit OAuth:', error);
alert('Error completing Fitbit OAuth: ' + error.message);
}
}
// Handle MFA submission
document.getElementById('submit-mfa-btn').addEventListener('click', submitMFA);
async function submitMFA() {
const mfaCode = document.getElementById('mfa-code').value.trim();
if (!mfaCode) {
alert('Please enter the verification code');
return;
}
try {
const response = await fetch('/api/setup/garmin/mfa', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
verification_code: mfaCode,
session_id: window.garmin_mfa_session_id
})
});
const data = await response.json();
if (response.ok) {
alert(data.message || 'MFA verification successful');
document.getElementById('garmin-mfa-section').style.display = 'none';
// Clear the MFA code field
document.getElementById('mfa-code').value = '';
// Refresh status after MFA
loadStatusInfo();
} else {
alert('MFA verification failed: ' + data.message);
}
} catch (error) {
console.error('Error submitting MFA code:', error);
alert('Error submitting MFA code: ' + error.message);
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,30 @@
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/fitbit_garmin_sync
- FITBIT_CLIENT_ID=${FITBIT_CLIENT_ID:-}
- FITBIT_CLIENT_SECRET=${FITBIT_CLIENT_SECRET:-}
- FITBIT_REDIRECT_URI=${FITBIT_REDIRECT_URI:-http://localhost:8000/api/setup/fitbit/callback}
depends_on:
- db
volumes:
- ./logs:/app/logs # For application logs
db:
image: postgres:15
environment:
- POSTGRES_DB=fitbit_garmin_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:

1200
FitnessSync/fitbitsync.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
garminconnect==0.2.30
garth==0.5.17
fitbit==0.3.1
sqlalchemy==2.0.23
asyncpg==0.29.0
psycopg2-binary==2.9.9
jinja2==3.1.2
python-dotenv==1.0.0
pydantic==2.1.1
requests==2.31.0
httpx==0.25.2
aiofiles==23.2.1
pytest==7.4.3
pytest-asyncio==0.21.1
alembic==1.13.1

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Fitbit-Garmin Local Sync
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: December 22, 2025
**Feature**: [Link to spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All validation items have been checked and the specification is ready for planning.

View File

@@ -0,0 +1,461 @@
openapi: 3.0.0
info:
title: Fitbit-Garmin Sync API
description: API for synchronizing health and fitness data between Fitbit and Garmin Connect platforms
version: 1.0.0
servers:
- url: http://localhost:8000
description: Development server
paths:
/api/status:
get:
summary: Get current sync status
description: Provides JSON data for the status dashboard including sync counts and recent logs
responses:
'200':
description: Current sync status
content:
application/json:
schema:
type: object
properties:
total_weight_records:
type: integer
description: Total number of weight records
synced_weight_records:
type: integer
description: Number of synced weight records
unsynced_weight_records:
type: integer
description: Number of unsynced weight records
total_activities:
type: integer
description: Total number of activities
downloaded_activities:
type: integer
description: Number of downloaded activities
recent_logs:
type: array
items:
$ref: '#/components/schemas/SyncLog'
/api/logs:
get:
summary: Get sync logs
description: Provides JSON data for the sync logs table
parameters:
- name: limit
in: query
schema:
type: integer
default: 20
description: Number of log entries to return
- name: offset
in: query
schema:
type: integer
default: 0
description: Offset for pagination
responses:
'200':
description: Array of sync log entries
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SyncLog'
/api/sync/weight:
post:
summary: Trigger weight sync
description: Starts the process of syncing weight data from Fitbit to Garmin
responses:
'200':
description: Weight sync initiated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "started"
message:
type: string
example: "Weight sync process started"
job_id:
type: string
example: "weight-sync-12345"
/api/sync/activities:
post:
summary: Trigger activity sync
description: Starts the process of archiving activities from Garmin to local storage
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
days_back:
type: integer
description: Number of days to look back for activities
example: 30
responses:
'200':
description: Activity sync initiated successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "started"
message:
type: string
example: "Activity sync process started"
job_id:
type: string
example: "activity-sync-12345"
/api/setup/garmin:
post:
summary: Save Garmin credentials
description: Saves Garmin credentials from the setup form
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
description: Garmin Connect username
password:
type: string
description: Garmin Connect password
responses:
'200':
description: Garmin credentials saved successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "success"
message:
type: string
example: "Garmin credentials saved"
/api/setup/fitbit:
post:
summary: Save Fitbit credentials
description: Saves Fitbit credentials and returns the auth URL
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
client_id:
type: string
description: Fitbit API client ID
client_secret:
type: string
description: Fitbit API client secret
responses:
'200':
description: Fitbit credentials saved and auth URL returned
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "success"
auth_url:
type: string
example: "https://www.fitbit.com/oauth2/authorize?..."
message:
type: string
example: "Fitbit credentials saved, please visit auth_url to authorize"
/api/setup/fitbit/callback:
post:
summary: Complete Fitbit OAuth flow
description: Completes the Fitbit OAuth flow with the callback URL
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
callback_url:
type: string
description: Full callback URL from the browser after authorizing the Fitbit app
responses:
'200':
description: Fitbit OAuth flow completed successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: "success"
message:
type: string
example: "Fitbit OAuth flow completed successfully"
/api/metrics/list:
get:
summary: List available metric types
description: Returns a list of available metric types and date ranges
responses:
'200':
description: List of available metric types
content:
application/json:
schema:
type: object
properties:
metric_types:
type: array
items:
type: string
example: ["steps", "heart_rate", "sleep", "calories"]
date_range:
type: object
properties:
start_date:
type: string
format: date
end_date:
type: string
format: date
/api/metrics/query:
get:
summary: Query health metrics
description: Allows filtering and retrieval of specific metrics by date range, type, or other criteria
parameters:
- name: metric_type
in: query
schema:
type: string
description: Type of metric to retrieve
- name: start_date
in: query
schema:
type: string
format: date
description: Start date for the query
- name: end_date
in: query
schema:
type: string
format: date
description: End date for the query
- name: limit
in: query
schema:
type: integer
default: 100
description: Number of records to return
responses:
'200':
description: Array of health metrics
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/HealthMetric'
/api/activities/list:
get:
summary: List available activities
description: Returns metadata for all downloaded/available activities
parameters:
- name: limit
in: query
schema:
type: integer
default: 50
description: Number of activities to return
- name: offset
in: query
schema:
type: integer
default: 0
description: Offset for pagination
responses:
'200':
description: Array of activity metadata
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Activity'
/api/activities/query:
get:
summary: Query activities
description: Allows advanced filtering of activities by type, date, duration, etc.
parameters:
- name: activity_type
in: query
schema:
type: string
description: Type of activity to filter
- name: start_date
in: query
schema:
type: string
format: date
description: Start date for the query
- name: end_date
in: query
schema:
type: string
format: date
description: End date for the query
- name: download_status
in: query
schema:
type: string
enum: [pending, downloaded, failed]
description: Download status to filter
responses:
'200':
description: Array of activities matching the filter
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Activity'
/api/health-data/summary:
get:
summary: Get health data summary
description: Provides aggregated health statistics
parameters:
- name: start_date
in: query
schema:
type: string
format: date
description: Start date for the summary
- name: end_date
in: query
schema:
type: string
format: date
description: End date for the summary
responses:
'200':
description: Aggregated health statistics
content:
application/json:
schema:
type: object
properties:
total_steps:
type: integer
avg_heart_rate:
type: number
format: float
total_sleep_hours:
type: number
format: float
avg_calories:
type: number
format: float
components:
schemas:
SyncLog:
type: object
properties:
id:
type: integer
operation:
type: string
enum: [weight_sync, activity_archive, metrics_download]
status:
type: string
enum: [started, in_progress, completed, failed]
message:
type: string
start_time:
type: string
format: date-time
end_time:
type: string
format: date-time
records_processed:
type: integer
records_failed:
type: integer
HealthMetric:
type: object
properties:
id:
type: integer
metric_type:
type: string
metric_value:
type: number
unit:
type: string
timestamp:
type: string
format: date-time
date:
type: string
format: date
source:
type: string
detailed_data:
type: object
Activity:
type: object
properties:
id:
type: integer
garmin_activity_id:
type: string
activity_name:
type: string
activity_type:
type: string
start_time:
type: string
format: date-time
duration:
type: integer
file_path:
type: string
file_type:
type: string
download_status:
type: string
enum: [pending, downloaded, failed]
downloaded_at:
type: string
format: date-time

View File

@@ -0,0 +1,117 @@
# Data Model: Fitbit-Garmin Local Sync
## Overview
This document defines the data models for the Fitbit-Garmin Local Sync application based on the key entities identified in the feature specification.
## Entity: Configuration
**Description**: Application settings including API credentials, sync settings, and database connection parameters
**Fields**:
- `id` (Integer): Unique identifier for the configuration record
- `fitbit_client_id` (String): Fitbit API client ID
- `fitbit_client_secret` (String): Fitbit API client secret (encrypted)
- `garmin_username` (String): Garmin Connect username
- `garmin_password` (String): Garmin Connect password (encrypted)
- `sync_settings` (JSON): Sync preferences and settings
- `created_at` (DateTime): Timestamp of creation
- `updated_at` (DateTime): Timestamp of last update
## Entity: Weight Record
**Description**: Individual weight entries with timestamps, values, and sync status with unique identifiers to prevent duplicate processing
**Fields**:
- `id` (Integer): Unique identifier for the weight record
- `fitbit_id` (String): Original Fitbit ID for the weight entry
- `weight` (Float): Weight value in user's preferred units
- `unit` (String): Weight unit (e.g., 'kg', 'lbs')
- `date` (Date): Date of the weight measurement
- `timestamp` (DateTime): Exact timestamp of the measurement
- `sync_status` (String): Sync status ('unsynced', 'synced', 'failed')
- `garmin_id` (String, nullable): ID of the record if synced to Garmin
- `created_at` (DateTime): Timestamp of record creation
- `updated_at` (DateTime): Timestamp of last update
## Entity: Activity Metadata
**Description**: Information about Garmin activities including download status, file content stored in database, and activity details
**Fields**:
- `id` (Integer): Unique identifier for the activity record
- `garmin_activity_id` (String): Original Garmin ID for the activity
- `activity_name` (String): Name of the activity
- `activity_type` (String): Type of activity (e.g., 'running', 'cycling')
- `start_time` (DateTime): Start time of the activity
- `duration` (Integer): Duration in seconds
- `file_content` (LargeBinary, nullable): Activity file content stored in database (base64 encoded)
- `file_type` (String): File type (.fit, .gpx, .tcx, etc.)
- `download_status` (String): Download status ('pending', 'downloaded', 'failed')
- `downloaded_at` (DateTime, nullable): Timestamp when downloaded
- `created_at` (DateTime): Timestamp of record creation
- `updated_at` (DateTime): Timestamp of last update
## Entity: Health Metric
**Description**: Comprehensive health data including type, timestamp, values across categories (steps, calories, heart rate, sleep, etc.)
**Fields**:
- `id` (Integer): Unique identifier for the health metric record
- `metric_type` (String): Type of metric (e.g., 'steps', 'heart_rate', 'sleep', 'calories')
- `metric_value` (Float): Value of the metric
- `unit` (String): Unit of measurement
- `timestamp` (DateTime): When the metric was recorded
- `date` (Date): Date of the metric
- `source` (String): Source of the metric ('garmin')
- `detailed_data` (JSON, nullable): Additional details specific to the metric type
- `created_at` (DateTime): Timestamp of record creation
- `updated_at` (DateTime): Timestamp of last update
## Entity: Sync Log
**Description**: Operation logs with timestamps, status, and results for monitoring and troubleshooting
**Fields**:
- `id` (Integer): Unique identifier for the sync log entry
- `operation` (String): Type of sync operation ('weight_sync', 'activity_archive', 'metrics_download')
- `status` (String): Status of the operation ('started', 'in_progress', 'completed', 'failed')
- `message` (String): Status message or error details
- `start_time` (DateTime): When the operation started
- `end_time` (DateTime, nullable): When the operation completed
- `records_processed` (Integer): Number of records processed
- `records_failed` (Integer): Number of records that failed
- `user_id` (Integer, nullable): Reference to user (if applicable)
## Entity: API Token
**Description**: OAuth tokens for Fitbit and Garmin with expiration tracking and refresh mechanisms
**Fields**:
- `id` (Integer): Unique identifier for the token record
- `token_type` (String): Type of token ('fitbit', 'garmin')
- `access_token` (String): Access token (encrypted)
- `refresh_token` (String): Refresh token (encrypted)
- `expires_at` (DateTime): When the token expires
- `scopes` (String): OAuth scopes granted
- `last_used` (DateTime): When the token was last used
- `created_at` (DateTime): Timestamp of record creation
- `updated_at` (DateTime): Timestamp of last update
## Entity: Auth Status
**Description**: Current authentication state for both Fitbit and Garmin, including token expiration times and last login information
**Fields**:
- `id` (Integer): Unique identifier for the auth status record
- `service_type` (String): Type of service ('fitbit', 'garmin')
- `username` (String): Username for the service (masked for security display)
- `authenticated` (Boolean): Whether currently authenticated
- `token_expires_at` (DateTime): When the current token expires
- `last_login` (DateTime): When the last successful login occurred
- `is_china` (Boolean): Whether using garmin.cn domain (Garmin only)
- `last_check` (DateTime): When status was last checked
- `created_at` (DateTime): Timestamp of record creation
- `updated_at` (DateTime): Timestamp of last update
## Relationships
- Configuration has many API Tokens
- Authentication Status references API Tokens
- Sync Logs reference Configuration
- Weight Records may reference API Tokens for sync operations
- Activity Metadata may reference API Tokens for download operations
- Health Metrics may reference API Tokens for retrieval operations
## Validation Rules
- Configuration records must have valid API credentials before sync operations
- Weight Records must have unique fitbit_id to prevent duplicates
- Activity Metadata records must have unique garmin_activity_id
- Health Metric records must have valid metric_type from allowed list
- Sync Log records must have valid operation and status values
- API Token records must be refreshed before expiration
- Authentication status must be updated when tokens are refreshed

View File

@@ -0,0 +1,103 @@
# Implementation Plan: Fitbit-Garmin Local Sync
**Branch**: `001-fitbit-garmin-sync` | **Date**: December 22, 2025 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-fitbit-garmin-sync/spec.md`
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/templates/commands/plan.md` for the execution workflow.
## Summary
This feature implements a standalone Python application that synchronizes health and fitness data between Fitbit and Garmin Connect platforms. The primary requirements include: 1) Weight data synchronization from Fitbit to Garmin, 2) Activity file archiving from Garmin to local storage, and 3) Comprehensive health metrics download from Garmin to local database. The system uses a web interface with API endpoints for user interaction and operates with local-only data storage for privacy.
## Technical Context
**Language/Version**: Python 3.11
**Primary Dependencies**: FastAPI, uvicorn, garminconnect, garth, fitbit, SQLAlchemy, Jinja2, psycopg2
**Storage**: PostgreSQL database for all data including configuration, health metrics, activity files, and authentication status information
**Testing**: pytest for unit and integration tests, contract tests for API endpoints
**Target Platform**: Linux server (containerized with Docker)
**Project Type**: Web application (backend API + web UI)
**Performance Goals**: Process 1000 activity files within 2 hours, sync weight data with 95% success rate, API responses under 3 seconds
**Constraints**: All sensitive data stored locally, offline-capable operation, secure storage of OAuth tokens
**Scale/Scope**: Single user system supporting personal health data synchronization
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
Based on the project constitution, this implementation needs to follow library-first principles, exposing functionality via web API. All new features should have tests written before implementation, with integration tests for API contracts and inter-service communication. The system must include structured logging and observability for debugging.
## Project Structure
### Documentation (this feature)
```text
specs/001-fitbit-garmin-sync/
├── plan.md # This file (/speckit.plan command output)
├── research.md # Phase 0 output (/speckit.plan command)
├── data-model.md # Phase 1 output (/speckit.plan command)
├── quickstart.md # Phase 1 output (/speckit.plan command)
├── contracts/ # Phase 1 output (/speckit.plan command)
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
```
### Source Code (repository root)
```text
backend/
├── main.py
├── src/
│ ├── models/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── weight_record.py
│ │ ├── activity.py
│ │ ├── health_metric.py
│ │ ├── sync_log.py
│ │ ├── api_token.py
│ │ └── auth_status.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── fitbit_client.py
│ │ ├── garmin_client.py
│ │ ├── postgresql_manager.py
│ │ └── sync_app.py
│ ├── api/
│ │ ├── __init__.py
│ │ ├── auth.py
│ │ ├── sync.py
│ │ ├── setup.py
│ │ └── metrics.py
│ └── utils/
│ ├── __init__.py
│ └── helpers.py
├── templates/
│ ├── index.html
│ └── setup.html
├── static/
│ ├── css/
│ └── js/
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
tests/
├── unit/
│ ├── test_models/
│ ├── test_services/
│ └── test_api/
├── integration/
│ └── test_sync_flow.py
└── contract/
└── test_api_contracts.py
```
**Structure Decision**: Web application structure selected to support the backend API and web UI requirements from the feature specification. The backend includes models for data representation, services for business logic, and API endpoints for user interaction.
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| External API dependencies | Required for Fitbit and Garmin integration | Direct DB access insufficient for external services |

View File

@@ -0,0 +1,102 @@
# Quickstart Guide: Fitbit-Garmin Local Sync
## Prerequisites
- Python 3.11+
- PostgreSQL database
- Docker and Docker Compose (for containerized deployment)
- Fitbit Developer Account (to create an app and get API credentials)
- Garmin Connect Account
## Setup
### 1. Clone and Install Dependencies
```bash
# Clone the repository
git clone <repository-url>
cd fitbit-garmin-sync
# Create virtual environment
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
```
### 2. Database Setup
```bash
# Create PostgreSQL database
createdb fitbit_garmin_sync
# Update database configuration in application
# The application will handle schema creation automatically
```
### 3. Environment Configuration
Create a `.env` file with the following:
```env
DATABASE_URL=postgresql://username:password@localhost:5432/fitbit_garmin_sync
FITBIT_CLIENT_ID=your_fitbit_client_id
FITBIT_CLIENT_SECRET=your_fitbit_client_secret
FITBIT_REDIRECT_URI=http://localhost:8000/api/setup/fitbit/callback
```
### 4. Run the Application
```bash
# Using uvicorn directly
uvicorn main:app --host 0.0.0.0 --port 8000
# Or using Docker
docker-compose up --build
```
## Initial Configuration
1. Open the application in your browser at `http://localhost:8000`
2. Navigate to the Setup page (`/setup`)
3. Enter your Garmin Connect username and password
4. Enter your Fitbit Client ID and Client Secret
5. Click the authorization link provided to authenticate with Fitbit
6. Copy the full callback URL from your browser after authorizing and paste it into the input field on the setup page
## Using the Application
### Sync Weight Data
1. Go to the home page (`/`)
2. Click the "Sync Weight" button
3. Monitor the sync status in the logs table
### Archive Activities
1. Go to the home page (`/`)
2. Click the "Sync Activities" button
3. Enter the number of days back to look for activities
4. Monitor the sync status in the logs table
### View Health Metrics
1. Use the API endpoints to query health metrics:
- `/api/metrics/list` - List available metric types
- `/api/metrics/query` - Query specific metrics
- `/api/health-data/summary` - Get aggregated health statistics
## Docker Deployment
```bash
# Build and run with Docker Compose
docker-compose up --build
# The application will be available at http://localhost:8000
# PostgreSQL database will be automatically set up
```
## API Endpoints
See the full API documentation in the `contracts/api-contract.yaml` file or access the automatic documentation at `/docs` when running the application.

View File

@@ -0,0 +1,58 @@
# Research: Fitbit-Garmin Local Sync
## Overview
This document captures research findings for the Fitbit-Garmin Local Sync feature, addressing technology choices, best practices, and integration patterns.
## Decision: Python 3.11 as primary language
**Rationale**: Python is well-suited for API integrations and web applications. Version 3.11 offers performance improvements and is widely supported by the required libraries (FastAPI, garminconnect, fitbit).
**Alternatives considered**: Node.js/JavaScript, Go, Rust - Python has the most mature ecosystem for health data API integrations.
## Decision: FastAPI for web framework
**Rationale**: FastAPI provides automatic API documentation (OpenAPI), type validation, asynchronous support, and excellent performance. It's ideal for both the API endpoints and web UI rendering.
**Alternatives considered**: Flask (less modern features), Django (too heavy for this use case), Starlette (requires more manual work).
## Decision: garminconnect and garth libraries for Garmin integration
**Rationale**: garminconnect is the most actively maintained Python library for Garmin Connect API. garth handles authentication, including the complex authentication flow for Garmin's API.
**Alternatives considered**: Custom HTTP requests implementation (more error-prone), selenium for web scraping (against ToS and less reliable).
## Decision: Fitbit official Python library
**Rationale**: The official fitbit library provides proper OAuth 2.0 handling and is maintained by Fitbit. It includes all necessary endpoints for weight data retrieval.
**Alternatives considered**: Direct API calls with requests library (would require more OAuth management code).
## Decision: PostgreSQL for data storage
**Rationale**: PostgreSQL provides ACID compliance, robustness, and complex query capabilities needed for health metrics. It supports the data types needed for timestamps and metric values.
**Alternatives considered**: SQLite (simpler but less scalable), MongoDB (document-based which may not suit structured health data), MySQL (similar capabilities but PostgreSQL has better JSON support).
## Decision: SQLAlchemy as ORM
**Rationale**: SQLAlchemy provides database abstraction, migration support, and protection against SQL injection. It works well with FastAPI and supports asynchronous operations.
**Alternatives considered**: Peewee (simpler but less feature-rich), Django ORM (requires Django framework), direct database connectors (more error-prone).
## Decision: Docker for deployment
**Rationale**: Docker provides consistent deployment across environments, easy dependency management, and isolation. It's the standard for modern application deployment.
**Alternatives considered**: Direct installation on host system (harder to manage dependencies), virtual environments (doesn't solve system-level dependency issues).
## Decision: Jinja2 for templating
**Rationale**: Jinja2 is the standard Python templating engine, supported by FastAPI. It provides the right balance of functionality and simplicity for the web interface.
**Alternatives considered**: Mako, Chameleon (less common), building HTML responses directly (not maintainable).
## Authentication Research
- **Fitbit OAuth 2.0**: Requires app registration with Fitbit, supports refresh tokens for long-term access
- **Garmin authentication**: Uses garth library to handle OAuth 1.0a/2.0 hybrid, stores session tokens for reuse
- **Multi-Factor Authentication (MFA)**: Garmin may require MFA for accounts with enhanced security. The garth library handles MFA flows by prompting for verification codes when required
- **Security**: Both systems support proper token refresh and secure storage
## API Rate Limiting Considerations
- **Fitbit**: Has rate limits (150 req/hour for user endpoints) - need to implement backoff/retry logic
- **Garmin**: No official rate limits published, but need to be respectful to avoid being blocked
- **Best practice**: Implement exponential backoff and caching to minimize API calls
## Data Synchronization Strategy
- **Deduplication**: Use unique identifiers and timestamps to prevent duplicate processing
- **State tracking**: Store sync status in database to enable resumption of interrupted operations
- **Conflict resolution**: For weight data, prefer Fitbit as source of truth since the feature is to sync FROM Fitbit TO Garmin
## Error Handling Approach
- **Network errors**: Retry with exponential backoff
- **Authentication errors**: Detect and re-authenticate automatically
- **API errors**: Log with context and allow user to retry operations
- **Storage errors**: Validate disk space before downloading activity files

View File

@@ -0,0 +1,105 @@
# Feature Specification: Fitbit-Garmin Local Sync
**Feature Branch**: `001-fitbit-garmin-sync`
**Created**: December 22, 2025
**Status**: Draft
**Input**: User description: "Fitbit-Garmin Local Sync application to synchronize health and fitness data between Fitbit and Garmin Connect platforms"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Sync Weight Data from Fitbit to Garmin (Priority: P1)
A user with both Fitbit and Garmin devices wants to transfer their weight data from Fitbit to Garmin in a secure, automated way. The user accesses the web interface, triggers the sync process, and the system safely transfers their weight history from Fitbit to Garmin without duplicating entries.
**Why this priority**: This is the foundational functionality that provides immediate value to users with both platforms, solving a common data silo problem.
**Independent Test**: Can be fully tested by configuring Fitbit and Garmin credentials, triggering a weight sync, and verifying weight entries appear in Garmin without duplication. Delivers core value of unified health data.
**Acceptance Scenarios**:
1. **Given** user has configured Fitbit and Garmin credentials, **When** user clicks "Sync Weight" button, **Then** system fetches weight history from Fitbit and uploads to Garmin, tracking synced entries to prevent duplicates
2. **Given** user has previously synced weight data, **When** user runs sync again, **Then** system only uploads new weight entries that haven't been synced before
---
### User Story 2 - Archive Activity Files from Garmin (Priority: P2)
A user wants to preserve their historical activity data from Garmin by downloading original files (.fit, .gpx, .tcx) and storing them in the database. The user accesses the web interface, triggers the activity archiving process, and the system downloads activity files and stores them in the PostgreSQL database with proper organization. The system handles multi-factor authentication flows required by Garmin.
**Why this priority**: Provides backup and archival capabilities that users value for data preservation and analysis, building on the core sync functionality.
**Independent Test**: Can be tested by triggering the activity archiving process and verifying original activity files are downloaded and stored in the database with proper organization.
**Acceptance Scenarios**:
1. **Given** user has configured Garmin credentials, **When** user clicks "Archive Activities" button, **Then** system fetches activity list and downloads original files stored in database
2. **Given** user has previously downloaded activity files, **When** user runs archiving again, **Then** system only downloads activities that haven't been downloaded before
---
### User Story 3 - Download Comprehensive Health Metrics from Garmin (Priority: P3)
A user wants to store all available health metrics from Garmin for local analysis and backup. The user accesses the web interface, triggers the metrics download process, and the system retrieves a comprehensive range of health metrics (steps, calories, heart rate, sleep, etc.) and stores them in a local database.
**Why this priority**: Provides complete health data backup and analysis capabilities, extending beyond basic sync to comprehensive data management.
**Independent Test**: Can be tested by triggering the metrics download and verifying that various types of health metrics are stored in the database with proper timestamps and types.
**Acceptance Scenarios**:
1. **Given** user has configured Garmin credentials, **When** user triggers health metrics download, **Then** system retrieves and stores comprehensive health metrics in local database
2. **Given** user wants to query health metrics, **When** user makes API call to metrics endpoint, **Then** system returns requested metrics filtered by date range and type
---
### Edge Cases
- What happens when API rate limits are exceeded during sync operations?
- How does system handle authentication token expiration during long-running sync processes?
- What occurs when local storage is insufficient for activity file downloads?
- How does system handle duplicate data detection when timestamps are slightly different?
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST authenticate with both Fitbit (OAuth 2.0) and Garmin Connect (username/password with garth library) APIs
- **FR-002**: System MUST securely store API credentials, tokens, and configuration in a PostgreSQL database
- **FR-003**: System MUST synchronize weight data from Fitbit to Garmin, including historical records
- **FR-004**: System MUST track synced entries to prevent duplicate uploads
- **FR-005**: System MUST download original activity files (.fit, .gpx, .tcx) from Garmin and store them in the PostgreSQL database
- **FR-006**: System MUST download comprehensive health metrics from Garmin including daily summaries, heart rate, sleep data, stress levels, and body composition
- **FR-007**: System MUST provide a web-based user interface with status dashboard and sync controls
- **FR-008**: System MUST provide API endpoints for triggering sync operations and querying data
- **FR-009**: System MUST implement robust error handling and retry mechanisms for API failures
- **FR-010**: System MUST support both global (garmin.com) and China (garmin.cn) Garmin domains
- **FR-011**: System MUST automatically refresh OAuth tokens when they expire
- **FR-012**: System MUST log all sync operations with timestamps, status, and results
- **FR-013**: System MUST allow users to trigger sync operations through UI buttons
- **FR-014**: System MUST support querying health metrics by date range, type, and other criteria
- **FR-015**: System MUST implement ACID-compliant data storage for consistency and integrity
- **FR-016**: System MUST handle multi-factor authentication flows for Garmin Connect when required by user's account settings
- **FR-017**: System MUST provide current authentication status and token expiration information in the web UI
- **FR-018**: System MUST securely store and manage Garmin OAuth tokens and session information
### Key Entities
- **Configuration**: Application settings including API credentials, sync settings, and database connection parameters
- **Weight Record**: Individual weight entries with timestamps, values, and sync status with unique identifiers to prevent duplicate processing
- **Activity Metadata**: Information about Garmin activities including download status, file content stored in database, and activity details
- **Health Metric**: Comprehensive health data including type, timestamp, values across categories (steps, calories, heart rate, sleep, etc.)
- **Sync Log**: Operation logs with timestamps, status, and results for monitoring and troubleshooting
- **API Token**: OAuth tokens for Fitbit and Garmin with expiration tracking and refresh mechanisms
- **Authentication Status**: Current authentication state for both Fitbit and Garmin, including token expiration times and last login information
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can successfully sync weight data from Fitbit to Garmin with 95% success rate for valid entries
- **SC-002**: System can download and archive 1000 activity files without errors within 2 hours
- **SC-003**: System can retrieve and store comprehensive health metrics for a full year of data within 4 hours
- **SC-004**: All sensitive data (credentials, tokens, health stats) remains stored locally without external cloud services
- **SC-005**: User can complete setup and authentication for both Fitbit and Garmin within 10 minutes
- **SC-006**: System prevents duplicate data uploads/downloads with 99.9% accuracy
- **SC-007**: Web interface loads and responds to user actions within 3 seconds under normal conditions

View File

@@ -0,0 +1,256 @@
---
description: "Task list for Fitbit-Garmin Local Sync implementation"
---
# Tasks: Fitbit-Garmin Local Sync
**Input**: Design documents from `/specs/001-fitbit-garmin-sync/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [x] T001 Create project structure per implementation plan in backend/
- [x] T002 Initialize Python 3.11 project with FastAPI, SQLAlchemy, garminconnect, garth, fitbit dependencies in requirements.txt
- [x] T003 [P] Configure linting and formatting tools (black, flake8) in pyproject.toml
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
- [x] T004 Setup database schema and migrations framework using SQLAlchemy in backend/src/models/
- [x] T005 [P] Create base models (Configuration, API Token) in backend/src/models/config.py and backend/src/models/api_token.py
- [x] T006 [P] Setup API routing and middleware structure in backend/main.py
- [x] T007 Create database manager for PostgreSQL in backend/src/services/postgresql_manager.py
- [x] T008 Configure error handling and logging infrastructure in backend/src/utils/
- [x] T009 Setup environment configuration management in backend/src/utils/
- [x] T010 Create FastAPI app structure with proper routing in backend/main.py
- [x] T011 Setup Docker configuration with PostgreSQL in backend/Dockerfile and backend/docker-compose.yml
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - Sync Weight Data from Fitbit to Garmin (Priority: P1) 🎯 MVP
**Goal**: User can transfer weight data from Fitbit to Garmin without duplicating entries, with a web interface to trigger the sync.
**Independent Test**: Can be fully tested by configuring Fitbit and Garmin credentials, triggering a weight sync, and verifying weight entries appear in Garmin without duplication. Delivers core value of unified health data.
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T012 [P] [US1] Contract test for /api/sync/weight endpoint in backend/tests/contract/test_weight_sync.py
- [ ] T013 [P] [US1] Integration test for weight sync flow in backend/tests/integration/test_weight_sync_flow.py
### Implementation for User Story 1
- [x] T014 [P] [US1] Create Weight Record model in backend/src/models/weight_record.py
- [x] T015 [P] [US1] Create Sync Log model in backend/src/models/sync_log.py
- [x] T016 [US1] Implement Fitbit Client service in backend/src/services/fitbit_client.py
- [x] T017 [US1] Implement Garmin Client service in backend/src/services/garmin_client.py
- [x] T018 [US1] Implement weight sync logic in backend/src/services/sync_app.py
- [x] T019 [US1] Implement weight sync API endpoint in backend/src/api/sync.py
- [x] T020 [US1] Create status API endpoint in backend/src/api/status.py
- [x] T021 [US1] Add web UI for weight sync in backend/templates/index.html
- [x] T022 [US1] Add logging for weight sync operations in backend/src/utils/helpers.py
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - Archive Activity Files from Garmin (Priority: P2)
**Goal**: User can download and archive Garmin activity files (.fit, .gpx, .tcx) to a local directory structure with proper organization.
**Independent Test**: Can be tested by triggering the activity archiving process and verifying original activity files are downloaded to the specified local directory with proper organization.
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T023 [P] [US2] Contract test for /api/sync/activities endpoint in backend/tests/contract/test_activities_sync.py
- [ ] T024 [P] [US2] Integration test for activity archiving flow in backend/tests/integration/test_activity_flow.py
### Implementation for User Story 2
- [x] T025 [P] [US2] Create Activity Metadata model in backend/src/models/activity.py
- [x] T026 [US2] Extend Garmin Client service with activity download methods in backend/src/services/garmin_client.py
- [x] T027 [US2] Implement activity archiving logic in backend/src/services/sync_app.py
- [x] T028 [US2] Implement activity sync API endpoint in backend/src/api/sync.py
- [x] T029 [US2] Add activity list endpoint in backend/src/api/activities.py
- [x] T030 [US2] Update web UI to include activity archiving in backend/templates/index.html
- [x] T031 [US2] Add logging for activity archiving operations in backend/src/utils/helpers.py
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - Download Comprehensive Health Metrics from Garmin (Priority: P3)
**Goal**: User can download and store comprehensive health metrics from Garmin in a local database with API endpoints for querying.
**Independent Test**: Can be tested by triggering the metrics download and verifying that various types of health metrics are stored in the database with proper timestamps and types.
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T032 [P] [US3] Contract test for /api/metrics endpoints in backend/tests/contract/test_metrics_api.py
- [ ] T033 [P] [US3] Integration test for health metrics download flow in backend/tests/integration/test_metrics_flow.py
### Implementation for User Story 3
- [x] T034 [P] [US3] Create Health Metric model in backend/src/models/health_metric.py
- [x] T035 [US3] Extend Garmin Client service with comprehensive metrics download methods in backend/src/services/garmin_client.py
- [x] T036 [US3] Implement health metrics download logic in backend/src/services/sync_app.py
- [x] T037 [US3] Implement metrics list and query endpoints in backend/src/api/metrics.py
- [x] T038 [US3] Implement health data summary endpoint in backend/src/api/metrics.py
- [x] T039 [US3] Add UI elements for metrics management in backend/templates/
- [x] T040 [US3] Add logging for metrics download operations in backend/src/utils/helpers.py
**Checkpoint**: All user stories should now be independently functional
---
## Phase 6: Setup API Endpoints for Configuration (Cross-cutting)
**Goal**: Implement configuration and authentication endpoints that support all user stories
- [x] T041 [P] Create setup API endpoints in backend/src/api/setup.py
- [x] T042 [P] Implement Garmin credentials save endpoint in backend/src/api/setup.py
- [x] T043 [P] Implement Fitbit credentials save and auth URL endpoint in backend/src/api/setup.py
- [x] T044 [P] Implement Fitbit OAuth callback endpoint in backend/src/api/setup.py
- [x] T045 Create logs endpoint in backend/src/api/logs.py
- [x] T046 Update UI with setup page in backend/templates/setup.html
- [x] T047 [P] Create auth status model in backend/src/models/auth_status.py
- [x] T048 [P] Create auth status API endpoint in backend/src/api/setup.py
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [x] T047 [P] Documentation updates in backend/README.md
- [ ] T048 Code cleanup and refactoring
- [ ] T049 Performance optimization across all stories
- [ ] T050 [P] Additional unit tests (if requested) in backend/tests/unit/
- [ ] T051 Security hardening for OAuth token storage and API access
- [ ] T052 Run quickstart.md validation
- [ ] T053 Final integration testing across all features
- [x] T054 Update Docker configuration with all required services
- [x] T055 Create setup guide in backend/docs/setup.md
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Configuration Phase (Phase 6)**: Can proceed in parallel with user stories but may be needed first
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for /api/sync/weight endpoint in backend/tests/contract/test_weight_sync.py"
Task: "Integration test for weight sync flow in backend/tests/integration/test_weight_sync_flow.py"
# Launch all models for User Story 1 together:
Task: "Create Weight Record model in backend/src/models/weight_record.py"
Task: "Create Sync Log model in backend/src/models/sync_log.py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence