mirror of
https://github.com/sstent/GarminSync.git
synced 2026-01-25 16:42:20 +00:00
checkpoint 2
This commit is contained in:
@@ -19,6 +19,7 @@ RUN pip install --upgrade pip && \
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY garminsync/ ./garminsync/
|
COPY garminsync/ ./garminsync/
|
||||||
COPY migrations/ ./migrations/
|
COPY migrations/ ./migrations/
|
||||||
|
COPY migrations/alembic.ini ./alembic.ini
|
||||||
COPY tests/ ./tests/
|
COPY tests/ ./tests/
|
||||||
COPY entrypoint.sh .
|
COPY entrypoint.sh .
|
||||||
COPY patches/ ./patches/
|
COPY patches/ ./patches/
|
||||||
|
|||||||
221
cyclingpower.md
Normal file
221
cyclingpower.md
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
# Cycling FIT Analysis Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Extend the existing GarminSync FIT parser to calculate cycling-specific metrics including power estimation and singlespeed gear ratio analysis for activities without native power data.
|
||||||
|
|
||||||
|
## Phase 1: Core Infrastructure Setup
|
||||||
|
|
||||||
|
### 1.1 Database Schema Extensions
|
||||||
|
**File: `garminsync/database.py`**
|
||||||
|
- Extend existing `PowerAnalysis` table with cycling-specific fields:
|
||||||
|
```python
|
||||||
|
# Add to PowerAnalysis class:
|
||||||
|
peak_power_1s = Column(Float, nullable=True)
|
||||||
|
peak_power_5s = Column(Float, nullable=True)
|
||||||
|
peak_power_20s = Column(Float, nullable=True)
|
||||||
|
peak_power_300s = Column(Float, nullable=True)
|
||||||
|
normalized_power = Column(Float, nullable=True)
|
||||||
|
intensity_factor = Column(Float, nullable=True)
|
||||||
|
training_stress_score = Column(Float, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Extend existing `GearingAnalysis` table:
|
||||||
|
```python
|
||||||
|
# Add to GearingAnalysis class:
|
||||||
|
estimated_chainring_teeth = Column(Integer, nullable=True)
|
||||||
|
estimated_cassette_teeth = Column(Integer, nullable=True)
|
||||||
|
gear_ratio = Column(Float, nullable=True)
|
||||||
|
gear_inches = Column(Float, nullable=True)
|
||||||
|
development_meters = Column(Float, nullable=True)
|
||||||
|
confidence_score = Column(Float, nullable=True)
|
||||||
|
analysis_method = Column(String, default="singlespeed_estimation")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Enhanced FIT Parser
|
||||||
|
**File: `garminsync/fit_processor/parser.py`**
|
||||||
|
- Extend `FITParser` to extract cycling-specific data points:
|
||||||
|
```python
|
||||||
|
def _extract_cycling_data(self, message):
|
||||||
|
"""Extract cycling-specific metrics from FIT records"""
|
||||||
|
# GPS coordinates for elevation/gradient
|
||||||
|
# Speed and cadence for gear analysis
|
||||||
|
# Power data (if available) for validation
|
||||||
|
# Temperature for air density calculations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: Power Estimation Engine
|
||||||
|
|
||||||
|
### 2.1 Physics-Based Power Calculator
|
||||||
|
**New file: `garminsync/fit_processor/power_estimator.py`**
|
||||||
|
|
||||||
|
**Key Components:**
|
||||||
|
- **Environmental factors**: Air density, wind resistance, temperature
|
||||||
|
- **Bike specifications**: Weight (22 lbs = 10 kg), aerodynamic drag coefficient
|
||||||
|
- **Rider assumptions**: Weight (75 kg default), position (road bike)
|
||||||
|
- **Terrain analysis**: Gradient calculation from GPS elevation data
|
||||||
|
|
||||||
|
**Core Algorithm:**
|
||||||
|
```python
|
||||||
|
class PowerEstimator:
|
||||||
|
def __init__(self):
|
||||||
|
self.bike_weight_kg = 10.0 # 22 lbs
|
||||||
|
self.rider_weight_kg = 75.0 # Default assumption
|
||||||
|
self.drag_coefficient = 0.88 # Road bike
|
||||||
|
self.frontal_area_m2 = 0.4 # Typical road cycling position
|
||||||
|
self.rolling_resistance = 0.004 # Road tires
|
||||||
|
self.drivetrain_efficiency = 0.97
|
||||||
|
self.air_density = 1.225 # kg/m³ at sea level, 20°C
|
||||||
|
|
||||||
|
def calculate_power(self, speed_ms, gradient_percent,
|
||||||
|
air_temp_c=20, altitude_m=0):
|
||||||
|
"""Calculate estimated power using physics model"""
|
||||||
|
# Power = (Rolling + Gravity + Aerodynamic + Kinetic) / Efficiency
|
||||||
|
```
|
||||||
|
|
||||||
|
**Power Components:**
|
||||||
|
1. **Rolling resistance**: `P_roll = Crr × (m_bike + m_rider) × g × cos(θ) × v`
|
||||||
|
2. **Gravitational**: `P_grav = (m_bike + m_rider) × g × sin(θ) × v`
|
||||||
|
3. **Aerodynamic**: `P_aero = 0.5 × ρ × Cd × A × v³`
|
||||||
|
4. **Acceleration**: `P_accel = (m_bike + m_rider) × a × v`
|
||||||
|
|
||||||
|
### 2.2 Peak Power Analysis
|
||||||
|
**Methods:**
|
||||||
|
- 1-second, 5-second, 20-second, 5-minute peak power windows
|
||||||
|
- Normalized Power (NP) calculation using 30-second rolling average
|
||||||
|
- Training Stress Score (TSS) estimation based on NP and ride duration
|
||||||
|
|
||||||
|
## Phase 3: Singlespeed Gear Ratio Analysis
|
||||||
|
|
||||||
|
### 3.1 Gear Ratio Calculator
|
||||||
|
**New file: `garminsync/fit_processor/gear_analyzer.py`**
|
||||||
|
|
||||||
|
**Strategy:**
|
||||||
|
- Analyze flat terrain segments (gradient < 3%)
|
||||||
|
- Use speed/cadence relationship to determine gear ratio
|
||||||
|
- Test against common singlespeed ratios for 38t and 46t chainrings
|
||||||
|
- Calculate confidence scores based on data consistency
|
||||||
|
|
||||||
|
**Core Algorithm:**
|
||||||
|
```python
|
||||||
|
class SinglespeedAnalyzer:
|
||||||
|
def __init__(self):
|
||||||
|
self.chainring_options = [38, 46] # teeth
|
||||||
|
self.common_cogs = list(range(11, 28)) # 11t to 27t rear cogs
|
||||||
|
self.wheel_circumference_m = 2.096 # 700x25c tire
|
||||||
|
|
||||||
|
def analyze_gear_ratio(self, speed_data, cadence_data, gradient_data):
|
||||||
|
"""Determine most likely singlespeed gear ratio"""
|
||||||
|
# Filter for flat terrain segments
|
||||||
|
# Calculate gear ratio from speed/cadence
|
||||||
|
# Match against common ratios
|
||||||
|
# Return best fit with confidence score
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gear Metrics:**
|
||||||
|
- **Gear ratio**: Chainring teeth ÷ Cog teeth
|
||||||
|
- **Gear inches**: Gear ratio × wheel diameter (inches)
|
||||||
|
- **Development**: Distance traveled per pedal revolution (meters)
|
||||||
|
|
||||||
|
### 3.2 Analysis Methodology
|
||||||
|
1. **Segment filtering**: Identify flat terrain (gradient < 3%, speed > 15 km/h)
|
||||||
|
2. **Ratio calculation**: `gear_ratio = (speed_ms × 60) ÷ (cadence_rpm × wheel_circumference_m)`
|
||||||
|
3. **Ratio matching**: Compare calculated ratios against theoretical singlespeed options
|
||||||
|
4. **Confidence scoring**: Based on data consistency and segment duration
|
||||||
|
|
||||||
|
## Phase 4: Integration with Existing System
|
||||||
|
|
||||||
|
### 4.1 FIT Processing Workflow Enhancement
|
||||||
|
**File: `garminsync/fit_processor/analyzer.py`**
|
||||||
|
- Integrate power estimation and gear analysis into existing analysis workflow
|
||||||
|
- Add cycling-specific analysis triggers (detect cycling activities)
|
||||||
|
- Store results in database using existing schema
|
||||||
|
|
||||||
|
### 4.2 Database Population
|
||||||
|
**Migration strategy:**
|
||||||
|
- Extend existing migration system to handle new fields
|
||||||
|
- Process existing FIT files retroactively
|
||||||
|
- Add processing status tracking for cycling analysis
|
||||||
|
|
||||||
|
### 4.3 CLI Integration
|
||||||
|
**File: `garminsync/cli.py`**
|
||||||
|
- Add new command: `garminsync analyze --cycling --activity-id <id>`
|
||||||
|
- Add batch processing: `garminsync analyze --cycling --missing`
|
||||||
|
- Add reporting: `garminsync report --power-analysis --gear-analysis`
|
||||||
|
|
||||||
|
## Phase 5: Validation and Testing
|
||||||
|
|
||||||
|
### 5.1 Test Data Requirements
|
||||||
|
- FIT files with known power data for validation
|
||||||
|
- Various singlespeed configurations for gear ratio testing
|
||||||
|
- Different terrain types (flat, climbing, mixed)
|
||||||
|
|
||||||
|
### 5.2 Validation Methodology
|
||||||
|
- Compare estimated vs. actual power (where available)
|
||||||
|
- Validate gear ratio estimates against known bike configurations
|
||||||
|
- Test edge cases (very low/high cadence, extreme gradients)
|
||||||
|
|
||||||
|
### 5.3 Performance Optimization
|
||||||
|
- Efficient gradient calculation from GPS data
|
||||||
|
- Optimize power calculation loops for large datasets
|
||||||
|
- Cache intermediate calculations
|
||||||
|
|
||||||
|
## Phase 6: Advanced Features (Future)
|
||||||
|
|
||||||
|
### 6.1 Environmental Corrections
|
||||||
|
- Wind speed/direction integration
|
||||||
|
- Barometric pressure for accurate altitude
|
||||||
|
- Temperature-based air density adjustments
|
||||||
|
|
||||||
|
### 6.2 Machine Learning Enhancement
|
||||||
|
- Train models on validated power data
|
||||||
|
- Improve gear ratio detection accuracy
|
||||||
|
- Personalized power estimation based on rider history
|
||||||
|
|
||||||
|
### 6.3 Comparative Analysis
|
||||||
|
- Compare estimated metrics across rides
|
||||||
|
- Trend analysis for fitness progression
|
||||||
|
- Gear ratio optimization recommendations
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
**High Priority:**
|
||||||
|
1. Database schema extensions
|
||||||
|
2. Basic power estimation using physics model
|
||||||
|
3. Singlespeed gear ratio analysis for flat terrain
|
||||||
|
4. Integration with existing FIT processing pipeline
|
||||||
|
|
||||||
|
**Medium Priority:**
|
||||||
|
1. Peak power analysis (1s, 5s, 20s, 5min)
|
||||||
|
2. Normalized Power and TSS calculations
|
||||||
|
3. Advanced gear analysis with confidence scoring
|
||||||
|
4. CLI commands for analysis and reporting
|
||||||
|
|
||||||
|
**Low Priority:**
|
||||||
|
1. Environmental corrections (wind, pressure)
|
||||||
|
2. Machine learning enhancements
|
||||||
|
3. Advanced comparative analysis features
|
||||||
|
4. Web UI integration for visualizing results
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. **Power Estimation**: Within ±10% of actual power data (where available for validation)
|
||||||
|
2. **Gear Ratio Detection**: Correctly identify gear ratios within ±1 tooth accuracy
|
||||||
|
3. **Processing Speed**: Analyze typical FIT file (1-hour ride) in <5 seconds
|
||||||
|
4. **Data Coverage**: Successfully analyze 90%+ of cycling FIT files
|
||||||
|
5. **Integration**: Seamlessly integrate with existing GarminSync workflow
|
||||||
|
|
||||||
|
## File Structure Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
garminsync/
|
||||||
|
├── fit_processor/
|
||||||
|
│ ├── parser.py (enhanced)
|
||||||
|
│ ├── analyzer.py (enhanced)
|
||||||
|
│ ├── power_estimator.py (new)
|
||||||
|
│ └── gear_analyzer.py (new)
|
||||||
|
├── database.py (enhanced)
|
||||||
|
├── cli.py (enhanced)
|
||||||
|
└── migrate_cycling_analysis.py (new)
|
||||||
|
```
|
||||||
|
|
||||||
|
This plan provides a comprehensive roadmap for implementing cycling-specific FIT analysis while building on the existing GarminSync infrastructure and maintaining compatibility with current functionality.
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Run database migrations
|
# Conditionally run database migrations
|
||||||
echo "Running database migrations..."
|
if [ "${RUN_MIGRATIONS:-1}" = "1" ]; then
|
||||||
export ALEMBIC_CONFIG=${ALEMBIC_CONFIG:-./migrations/alembic.ini}
|
echo "$(date) - Starting database migrations..."
|
||||||
export ALEMBIC_SCRIPT_LOCATION=${ALEMBIC_SCRIPT_LOCATION:-./migrations/versions}
|
echo "ALEMBIC_CONFIG: ${ALEMBIC_CONFIG:-/app/migrations/alembic.ini}"
|
||||||
alembic upgrade head
|
echo "ALEMBIC_SCRIPT_LOCATION: ${ALEMBIC_SCRIPT_LOCATION:-/app/migrations/versions}"
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "Migration failed!" >&2
|
# Run migrations with timing
|
||||||
exit 1
|
start_time=$(date +%s)
|
||||||
|
export ALEMBIC_CONFIG=${ALEMBIC_CONFIG:-/app/migrations/alembic.ini}
|
||||||
|
export ALEMBIC_SCRIPT_LOCATION=${ALEMBIC_SCRIPT_LOCATION:-/app/migrations/versions}
|
||||||
|
alembic upgrade head
|
||||||
|
migration_status=$?
|
||||||
|
end_time=$(date +%s)
|
||||||
|
duration=$((end_time - start_time))
|
||||||
|
|
||||||
|
if [ $migration_status -ne 0 ]; then
|
||||||
|
echo "$(date) - Migration failed after ${duration} seconds!" >&2
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "$(date) - Migrations completed successfully in ${duration} seconds"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "$(date) - Skipping database migrations (RUN_MIGRATIONS=${RUN_MIGRATIONS})"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
echo "Starting application..."
|
echo "$(date) - Starting application..."
|
||||||
exec python -m garminsync.cli daemon --start --port 8888
|
exec python -m garminsync.cli daemon --start --port 8888
|
||||||
sleep infinity
|
sleep infinity
|
||||||
|
|||||||
130
garminsync/activity_parser.py
Normal file
130
garminsync/activity_parser.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import os
|
||||||
|
import gzip
|
||||||
|
import fitdecode
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def detect_file_type(file_path):
|
||||||
|
"""Detect file format (FIT, XML, or unknown)"""
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
header = f.read(128)
|
||||||
|
if b'<?xml' in header[:20]:
|
||||||
|
return 'xml'
|
||||||
|
if len(header) >= 8 and header[4:8] == b'.FIT':
|
||||||
|
return 'fit'
|
||||||
|
if (len(header) >= 8 and
|
||||||
|
(header[0:4] == b'.FIT' or
|
||||||
|
header[4:8] == b'FIT.' or
|
||||||
|
header[8:12] == b'.FIT')):
|
||||||
|
return 'fit'
|
||||||
|
return 'unknown'
|
||||||
|
except Exception as e:
|
||||||
|
return 'error'
|
||||||
|
|
||||||
|
def parse_xml_file(file_path):
|
||||||
|
"""Parse XML (TCX) file to extract activity metrics"""
|
||||||
|
try:
|
||||||
|
tree = ET.parse(file_path)
|
||||||
|
root = tree.getroot()
|
||||||
|
namespaces = {'ns': 'http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2'}
|
||||||
|
|
||||||
|
sport = root.find('.//ns:Activity', namespaces).get('Sport', 'other')
|
||||||
|
distance = root.find('.//ns:DistanceMeters', namespaces)
|
||||||
|
distance = float(distance.text) if distance is not None else None
|
||||||
|
duration = root.find('.//ns:TotalTimeSeconds', namespaces)
|
||||||
|
duration = float(duration.text) if duration is not None else None
|
||||||
|
calories = root.find('.//ns:Calories', namespaces)
|
||||||
|
calories = int(calories.text) if calories is not None else None
|
||||||
|
|
||||||
|
hr_values = []
|
||||||
|
for hr in root.findall('.//ns:HeartRateBpm/ns:Value', namespaces):
|
||||||
|
try:
|
||||||
|
hr_values.append(int(hr.text))
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
max_hr = max(hr_values) if hr_values else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activityType": {"typeKey": sport},
|
||||||
|
"summaryDTO": {
|
||||||
|
"duration": duration,
|
||||||
|
"distance": distance,
|
||||||
|
"maxHR": max_hr,
|
||||||
|
"avgPower": None,
|
||||||
|
"calories": calories
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_fit_file(file_path):
|
||||||
|
"""Parse FIT file to extract activity metrics"""
|
||||||
|
metrics = {}
|
||||||
|
try:
|
||||||
|
with open(file_path, 'rb') as f:
|
||||||
|
magic = f.read(2)
|
||||||
|
f.seek(0)
|
||||||
|
is_gzipped = magic == b'\x1f\x8b'
|
||||||
|
|
||||||
|
if is_gzipped:
|
||||||
|
with gzip.open(file_path, 'rb') as gz_file:
|
||||||
|
from io import BytesIO
|
||||||
|
with BytesIO(gz_file.read()) as fit_data:
|
||||||
|
fit = fitdecode.FitReader(fit_data)
|
||||||
|
for frame in fit:
|
||||||
|
if frame.frame_type == fitdecode.FrameType.DATA and frame.name == 'session':
|
||||||
|
metrics = {
|
||||||
|
"sport": frame.get_value("sport"),
|
||||||
|
"total_timer_time": frame.get_value("total_timer_time"),
|
||||||
|
"total_distance": frame.get_value("total_distance"),
|
||||||
|
"max_heart_rate": frame.get_value("max_heart_rate"),
|
||||||
|
"avg_power": frame.get_value("avg_power"),
|
||||||
|
"total_calories": frame.get_value("total_calories")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
with fitdecode.FitReader(file_path) as fit:
|
||||||
|
for frame in fit:
|
||||||
|
if frame.frame_type == fitdecode.FrameType.DATA and frame.name == 'session':
|
||||||
|
metrics = {
|
||||||
|
"sport": frame.get_value("sport"),
|
||||||
|
"total_timer_time": frame.get_value("total_timer_time"),
|
||||||
|
"total_distance": frame.get_value("total_distance"),
|
||||||
|
"max_heart_rate": frame.get_value("max_heart_rate"),
|
||||||
|
"avg_power": frame.get_value("avg_power"),
|
||||||
|
"total_calories": frame.get_value("total_calories")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activityType": {"typeKey": metrics.get("sport", "other")},
|
||||||
|
"summaryDTO": {
|
||||||
|
"duration": metrics.get("total_timer_time"),
|
||||||
|
"distance": metrics.get("total_distance"),
|
||||||
|
"maxHR": metrics.get("max_heart_rate"),
|
||||||
|
"avgPower": metrics.get("avg_power"),
|
||||||
|
"calories": metrics.get("total_calories")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_activity_metrics(activity, client=None):
|
||||||
|
"""
|
||||||
|
Get activity metrics from local file or Garmin API
|
||||||
|
Returns parsed metrics or None
|
||||||
|
"""
|
||||||
|
metrics = None
|
||||||
|
if activity.filename and os.path.exists(activity.filename):
|
||||||
|
file_type = detect_file_type(activity.filename)
|
||||||
|
if file_type == 'fit':
|
||||||
|
metrics = parse_fit_file(activity.filename)
|
||||||
|
elif file_type == 'xml':
|
||||||
|
metrics = parse_xml_file(activity.filename)
|
||||||
|
if not metrics and client:
|
||||||
|
try:
|
||||||
|
metrics = client.get_activity_details(activity.activity_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return metrics
|
||||||
@@ -173,13 +173,20 @@ def daemon_mode(
|
|||||||
bool, typer.Option("--status", help="Show daemon status")
|
bool, typer.Option("--status", help="Show daemon status")
|
||||||
] = False,
|
] = False,
|
||||||
port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080,
|
port: Annotated[int, typer.Option("--port", help="Web UI port")] = 8080,
|
||||||
|
run_migrations: Annotated[
|
||||||
|
bool,
|
||||||
|
typer.Option(
|
||||||
|
"--run-migrations/--skip-migrations",
|
||||||
|
help="Run database migrations on startup (default: run)"
|
||||||
|
)
|
||||||
|
] = True,
|
||||||
):
|
):
|
||||||
"""Daemon mode operations"""
|
"""Daemon mode operations"""
|
||||||
from .daemon import GarminSyncDaemon
|
from .daemon import GarminSyncDaemon
|
||||||
|
|
||||||
if start:
|
if start:
|
||||||
daemon = GarminSyncDaemon()
|
daemon = GarminSyncDaemon()
|
||||||
daemon.start(web_port=port)
|
daemon.start(web_port=port, run_migrations=run_migrations)
|
||||||
elif stop:
|
elif stop:
|
||||||
# Implementation for stopping daemon (PID file or signal)
|
# Implementation for stopping daemon (PID file or signal)
|
||||||
typer.echo("Stopping daemon...")
|
typer.echo("Stopping daemon...")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from apscheduler.triggers.cron import CronTrigger
|
|||||||
from .database import Activity, DaemonConfig, SyncLog, get_session
|
from .database import Activity, DaemonConfig, SyncLog, get_session
|
||||||
from .garmin import GarminClient
|
from .garmin import GarminClient
|
||||||
from .utils import logger
|
from .utils import logger
|
||||||
|
from .activity_parser import get_activity_metrics
|
||||||
|
|
||||||
|
|
||||||
class GarminSyncDaemon:
|
class GarminSyncDaemon:
|
||||||
@@ -18,8 +19,17 @@ class GarminSyncDaemon:
|
|||||||
self.running = False
|
self.running = False
|
||||||
self.web_server = None
|
self.web_server = None
|
||||||
|
|
||||||
def start(self, web_port=8888):
|
def start(self, web_port=8888, run_migrations=True):
|
||||||
"""Start daemon with scheduler and web UI"""
|
"""Start daemon with scheduler and web UI
|
||||||
|
:param web_port: Port for the web UI
|
||||||
|
:param run_migrations: Whether to run database migrations on startup
|
||||||
|
"""
|
||||||
|
# Set migration flag for entrypoint
|
||||||
|
if run_migrations:
|
||||||
|
os.environ['RUN_MIGRATIONS'] = "1"
|
||||||
|
else:
|
||||||
|
os.environ['RUN_MIGRATIONS'] = "0"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load configuration from database
|
# Load configuration from database
|
||||||
config_data = self.load_config()
|
config_data = self.load_config()
|
||||||
@@ -105,26 +115,38 @@ class GarminSyncDaemon:
|
|||||||
|
|
||||||
for activity in missing_activities:
|
for activity in missing_activities:
|
||||||
try:
|
try:
|
||||||
# Use the correct method name
|
# Download FIT file
|
||||||
fit_data = client.download_activity_fit(activity.activity_id)
|
fit_data = client.download_activity_fit(activity.activity_id)
|
||||||
|
|
||||||
# Save the file
|
# Save to file
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
data_dir = Path(os.getenv("DATA_DIR", "data"))
|
data_dir = Path(os.getenv("DATA_DIR", "data"))
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
|
timestamp = activity.start_time.replace(":", "-").replace(" ", "_")
|
||||||
filename = f"activity_{activity.activity_id}_{timestamp}.fit"
|
filename = f"activity_{activity.activity_id}_{timestamp}.fit"
|
||||||
filepath = data_dir / filename
|
filepath = data_dir / filename
|
||||||
|
|
||||||
with open(filepath, "wb") as f:
|
with open(filepath, "wb") as f:
|
||||||
f.write(fit_data)
|
f.write(fit_data)
|
||||||
|
|
||||||
|
# Update activity record
|
||||||
activity.filename = str(filepath)
|
activity.filename = str(filepath)
|
||||||
activity.downloaded = True
|
activity.downloaded = True
|
||||||
activity.last_sync = datetime.now().isoformat()
|
activity.last_sync = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Get metrics immediately after download
|
||||||
|
metrics = get_activity_metrics(activity, client)
|
||||||
|
if metrics:
|
||||||
|
# Update metrics if available
|
||||||
|
activity.activity_type = metrics.get("activityType", {}).get("typeKey")
|
||||||
|
activity.duration = int(float(metrics.get("summaryDTO", {}).get("duration", 0)))
|
||||||
|
activity.distance = float(metrics.get("summaryDTO", {}).get("distance", 0))
|
||||||
|
activity.max_heart_rate = int(float(metrics.get("summaryDTO", {}).get("maxHR", 0)))
|
||||||
|
activity.avg_power = float(metrics.get("summaryDTO", {}).get("avgPower", 0))
|
||||||
|
activity.calories = int(float(metrics.get("summaryDTO", {}).get("calories", 0)))
|
||||||
|
|
||||||
|
session.commit()
|
||||||
downloaded_count += 1
|
downloaded_count += 1
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
@@ -135,7 +157,8 @@ class GarminSyncDaemon:
|
|||||||
session.rollback()
|
session.rollback()
|
||||||
|
|
||||||
self.log_operation(
|
self.log_operation(
|
||||||
"sync", "success", f"Downloaded {downloaded_count} new activities"
|
"sync", "success",
|
||||||
|
f"Downloaded {downloaded_count} new activities and updated metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update last run time
|
# Update last run time
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ def get_session():
|
|||||||
return Session()
|
return Session()
|
||||||
|
|
||||||
|
|
||||||
|
from garminsync.activity_parser import get_activity_metrics
|
||||||
|
|
||||||
def sync_database(garmin_client):
|
def sync_database(garmin_client):
|
||||||
"""Sync local database with Garmin Connect activities.
|
"""Sync local database with Garmin Connect activities.
|
||||||
|
|
||||||
@@ -134,36 +136,70 @@ def sync_database(garmin_client):
|
|||||||
print("No activities returned from Garmin API")
|
print("No activities returned from Garmin API")
|
||||||
return
|
return
|
||||||
|
|
||||||
for activity in activities:
|
for activity_data in activities:
|
||||||
# Check if activity is a dictionary and has required fields
|
if not isinstance(activity_data, dict):
|
||||||
if not isinstance(activity, dict):
|
print(f"Invalid activity data: {activity_data}")
|
||||||
print(f"Invalid activity data: {activity}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Safely access dictionary keys
|
activity_id = activity_data.get("activityId")
|
||||||
activity_id = activity.get("activityId")
|
start_time = activity_data.get("startTimeLocal")
|
||||||
start_time = activity.get("startTimeLocal")
|
|
||||||
avg_heart_rate = activity.get("averageHR", None)
|
|
||||||
calories = activity.get("calories", None)
|
|
||||||
|
|
||||||
if not activity_id or not start_time:
|
if not activity_id or not start_time:
|
||||||
print(f"Missing required fields in activity: {activity}")
|
print(f"Missing required fields in activity: {activity_data}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
existing = (
|
existing = session.query(Activity).filter_by(activity_id=activity_id).first()
|
||||||
session.query(Activity).filter_by(activity_id=activity_id).first()
|
|
||||||
)
|
# Create or update basic activity info
|
||||||
if not existing:
|
if not existing:
|
||||||
new_activity = Activity(
|
activity = Activity(
|
||||||
activity_id=activity_id,
|
activity_id=activity_id,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
avg_heart_rate=avg_heart_rate,
|
|
||||||
calories=calories,
|
|
||||||
downloaded=False,
|
downloaded=False,
|
||||||
created_at=datetime.now().isoformat(),
|
created_at=datetime.now().isoformat(),
|
||||||
last_sync=datetime.now().isoformat(),
|
last_sync=datetime.now().isoformat(),
|
||||||
)
|
)
|
||||||
session.add(new_activity)
|
session.add(activity)
|
||||||
|
session.flush() # Assign ID
|
||||||
|
else:
|
||||||
|
activity = existing
|
||||||
|
|
||||||
|
# Update metrics using shared parser
|
||||||
|
metrics = get_activity_metrics(activity, garmin_client)
|
||||||
|
if metrics:
|
||||||
|
activity.activity_type = metrics.get("activityType", {}).get("typeKey")
|
||||||
|
|
||||||
|
# Extract duration in seconds
|
||||||
|
duration = metrics.get("summaryDTO", {}).get("duration")
|
||||||
|
if duration is not None:
|
||||||
|
activity.duration = int(float(duration))
|
||||||
|
|
||||||
|
# Extract distance in meters
|
||||||
|
distance = metrics.get("summaryDTO", {}).get("distance")
|
||||||
|
if distance is not None:
|
||||||
|
activity.distance = float(distance)
|
||||||
|
|
||||||
|
# Extract heart rates
|
||||||
|
max_hr = metrics.get("summaryDTO", {}).get("maxHR")
|
||||||
|
if max_hr is not None:
|
||||||
|
activity.max_heart_rate = int(float(max_hr))
|
||||||
|
|
||||||
|
avg_hr = metrics.get("summaryDTO", {}).get("avgHR", None) or \
|
||||||
|
metrics.get("summaryDTO", {}).get("averageHR", None)
|
||||||
|
if avg_hr is not None:
|
||||||
|
activity.avg_heart_rate = int(float(avg_hr))
|
||||||
|
|
||||||
|
# Extract power and calories
|
||||||
|
avg_power = metrics.get("summaryDTO", {}).get("avgPower")
|
||||||
|
if avg_power is not None:
|
||||||
|
activity.avg_power = float(avg_power)
|
||||||
|
|
||||||
|
calories = metrics.get("summaryDTO", {}).get("calories")
|
||||||
|
if calories is not None:
|
||||||
|
activity.calories = int(float(calories))
|
||||||
|
|
||||||
|
# Update sync timestamp
|
||||||
|
activity.last_sync = datetime.now().isoformat()
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Migration script to populate new activity fields from Garmin API
|
Migration script to populate new activity fields from FIT files or Garmin API
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -16,11 +16,22 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from garminsync.database import Activity, get_session, init_db
|
from garminsync.database import Activity, get_session, init_db
|
||||||
from garminsync.garmin import GarminClient
|
from garminsync.garmin import GarminClient
|
||||||
|
from garminsync.activity_parser import get_activity_metrics
|
||||||
|
|
||||||
|
|
||||||
def add_columns_to_database():
|
def add_columns_to_database():
|
||||||
"""Add new columns to the activities table if they don't exist"""
|
"""Add new columns to the activities table if they don't exist"""
|
||||||
print("Adding new columns to database...")
|
|
||||||
|
# Add the parent directory to the path to import garminsync modules
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from garminsync.database import Activity, get_session, init_db
|
||||||
|
from garminsync.garmin import GarminClient
|
||||||
|
|
||||||
|
|
||||||
|
def add_columns_to_database():
|
||||||
|
"""Add new columns to the activities table if they don't exist"""
|
||||||
|
print("Adding new columns to database...", flush=True)
|
||||||
|
|
||||||
# Get database engine
|
# Get database engine
|
||||||
db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db")
|
db_path = os.path.join(os.getenv("DATA_DIR", "data"), "garmin.db")
|
||||||
@@ -49,7 +60,7 @@ def add_columns_to_database():
|
|||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
for column_name in new_columns:
|
for column_name in new_columns:
|
||||||
if column_name not in existing_columns:
|
if column_name not in existing_columns:
|
||||||
print(f"Adding column {column_name}...")
|
print(f"Adding column {column_name}...", flush=True)
|
||||||
if column_name in ["distance", "avg_power"]:
|
if column_name in ["distance", "avg_power"]:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text(
|
text(
|
||||||
@@ -69,21 +80,22 @@ def add_columns_to_database():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
print(f"Column {column_name} added successfully")
|
print(f"Column {column_name} added successfully", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f"Column {column_name} already exists")
|
print(f"Column {column_name} already exists", flush=True)
|
||||||
|
|
||||||
print("Database schema updated successfully")
|
print("Database schema updated successfully", flush=True)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to update database schema: {e}")
|
print(f"Failed to update database schema: {e}", flush=True)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_activities():
|
def migrate_activities():
|
||||||
"""Migrate activities to populate new fields from Garmin API"""
|
"""Migrate activities to populate new fields from FIT files or Garmin API"""
|
||||||
print("Starting activity migration...")
|
print("Starting activity migration...", flush=True)
|
||||||
|
|
||||||
# First, add columns to database
|
# First, add columns to database
|
||||||
if not add_columns_to_database():
|
if not add_columns_to_database():
|
||||||
@@ -92,9 +104,9 @@ def migrate_activities():
|
|||||||
# Initialize Garmin client
|
# Initialize Garmin client
|
||||||
try:
|
try:
|
||||||
client = GarminClient()
|
client = GarminClient()
|
||||||
print("Garmin client initialized successfully")
|
print("Garmin client initialized successfully", flush=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to initialize Garmin client: {e}")
|
print(f"Failed to initialize Garmin client: {e}", flush=True)
|
||||||
# Continue with migration but without Garmin data
|
# Continue with migration but without Garmin data
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
@@ -106,12 +118,12 @@ def migrate_activities():
|
|||||||
activities = (
|
activities = (
|
||||||
session.query(Activity).filter(Activity.activity_type.is_(None)).all()
|
session.query(Activity).filter(Activity.activity_type.is_(None)).all()
|
||||||
)
|
)
|
||||||
print(f"Found {len(activities)} activities to migrate")
|
print(f"Found {len(activities)} activities to migrate", flush=True)
|
||||||
|
|
||||||
# If no activities found, try to get all activities (in case activity_type column was just added)
|
# If no activities found, try to get all activities (in case activity_type column was just added)
|
||||||
if len(activities) == 0:
|
if len(activities) == 0:
|
||||||
activities = session.query(Activity).all()
|
activities = session.query(Activity).all()
|
||||||
print(f"Found {len(activities)} total activities")
|
print(f"Found {len(activities)} total activities", flush=True)
|
||||||
|
|
||||||
updated_count = 0
|
updated_count = 0
|
||||||
error_count = 0
|
error_count = 0
|
||||||
@@ -119,13 +131,16 @@ def migrate_activities():
|
|||||||
for i, activity in enumerate(activities):
|
for i, activity in enumerate(activities):
|
||||||
try:
|
try:
|
||||||
print(
|
print(
|
||||||
f"Processing activity {i+1}/{len(activities)} (ID: {activity.activity_id})"
|
f"Processing activity {i+1}/{len(activities)} (ID: {activity.activity_id})",
|
||||||
|
flush=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch detailed activity data from Garmin (if client is available)
|
# Use shared parser to get activity metrics
|
||||||
activity_details = None
|
activity_details = get_activity_metrics(activity, client)
|
||||||
if client:
|
if activity_details is not None:
|
||||||
activity_details = client.get_activity_details(activity.activity_id)
|
print(f" Successfully parsed metrics for activity {activity.activity_id}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f" Could not retrieve metrics for activity {activity.activity_id}", flush=True)
|
||||||
|
|
||||||
# Update activity fields if we have details
|
# Update activity fields if we have details
|
||||||
if activity_details:
|
if activity_details:
|
||||||
@@ -170,19 +185,19 @@ def migrate_activities():
|
|||||||
|
|
||||||
# Print progress every 10 activities
|
# Print progress every 10 activities
|
||||||
if (i + 1) % 10 == 0:
|
if (i + 1) % 10 == 0:
|
||||||
print(f" Progress: {i+1}/{len(activities)} activities processed")
|
print(f" Progress: {i+1}/{len(activities)} activities processed", flush=True)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Error processing activity {activity.activity_id}: {e}")
|
print(f" Error processing activity {activity.activity_id}: {e}", flush=True)
|
||||||
session.rollback()
|
session.rollback()
|
||||||
error_count += 1
|
error_count += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print(f"Migration completed. Updated: {updated_count}, Errors: {error_count}")
|
print(f"Migration completed. Updated: {updated_count}, Errors: {error_count}", flush=True)
|
||||||
return True # Allow partial success
|
return True # Allow partial success
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Migration failed: {e}")
|
print(f"Migration failed: {e}", flush=True)
|
||||||
return False
|
return False
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|||||||
9
justfile
9
justfile
@@ -9,11 +9,10 @@ dev:
|
|||||||
just build
|
just build
|
||||||
docker run -it --rm --env-file .env -v $(pwd)/garminsync:/app/garminsync -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync-dev garminsync uvicorn garminsync.web.app:app --reload --host 0.0.0.0 --port 8080
|
docker run -it --rm --env-file .env -v $(pwd)/garminsync:/app/garminsync -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync-dev garminsync uvicorn garminsync.web.app:app --reload --host 0.0.0.0 --port 8080
|
||||||
|
|
||||||
# Run database migrations (container-based)
|
# Run database migrations with enhanced logging (container-based)
|
||||||
migrate:
|
migrate:
|
||||||
just build
|
just build
|
||||||
docker run --rm --env-file .env -v $(pwd)/data:/app/data --entrypoint "alembic" garminsync upgrade head
|
docker run --rm --env-file .env -v $(pwd)/data:/app/data --entrypoint "python" garminsync -m garminsync.cli migrate
|
||||||
|
|
||||||
# Run validation tests (container-based)
|
# Run validation tests (container-based)
|
||||||
test:
|
test:
|
||||||
just build
|
just build
|
||||||
@@ -41,7 +40,7 @@ format:
|
|||||||
# Start production server
|
# Start production server
|
||||||
run_server:
|
run_server:
|
||||||
just build
|
just build
|
||||||
docker run -d --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
|
docker run -d --rm --env-file .env -e RUN_MIGRATIONS=1 -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
|
||||||
|
|
||||||
# Stop production server
|
# Stop production server
|
||||||
stop_server:
|
stop_server:
|
||||||
@@ -50,7 +49,7 @@ stop_server:
|
|||||||
# Run server in live mode for debugging
|
# Run server in live mode for debugging
|
||||||
run_server_live:
|
run_server_live:
|
||||||
just build
|
just build
|
||||||
docker run -it --rm --env-file .env -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
|
docker run -it --rm --env-file .env -e RUN_MIGRATIONS=1 -v $(pwd)/data:/app/data -p 8888:8888 --name garminsync garminsync daemon --start
|
||||||
|
|
||||||
# Clean up any existing container
|
# Clean up any existing container
|
||||||
cleanup:
|
cleanup:
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ tqdm==4.66.1
|
|||||||
sqlalchemy==2.0.30
|
sqlalchemy==2.0.30
|
||||||
pylint==3.1.0
|
pylint==3.1.0
|
||||||
pygments==2.18.0
|
pygments==2.18.0
|
||||||
|
fitdecode
|
||||||
|
|||||||
Binary file not shown.
@@ -1,102 +1,110 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
|
||||||
# Add the project root to the Python path
|
# Add the project root to the Python path
|
||||||
sys.path.insert(0, '/app')
|
sys.path.insert(0, '/app')
|
||||||
|
|
||||||
from garminsync.database import sync_database
|
from garminsync.database import sync_database, Activity, get_activity_metrics
|
||||||
from garminsync.garmin import GarminClient
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_database_with_valid_activities():
|
def test_sync_database_with_valid_activities():
|
||||||
"""Test sync_database with valid API response"""
|
"""Test sync_database with valid API response"""
|
||||||
mock_client = Mock(spec=GarminClient)
|
mock_client = Mock()
|
||||||
mock_client.get_activities.return_value = [
|
mock_client.get_activities.return_value = [
|
||||||
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"},
|
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"},
|
||||||
{"activityId": 67890, "startTimeLocal": "2023-01-02T11:00:00"}
|
{"activityId": 67890, "startTimeLocal": "2023-01-02T11:00:00"}
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('garminsync.database.get_session') as mock_session:
|
mock_session = MagicMock()
|
||||||
mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None
|
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||||
|
|
||||||
|
with patch('garminsync.database.get_session', return_value=mock_session), \
|
||||||
|
patch('garminsync.database.get_activity_metrics', return_value={
|
||||||
|
"activityType": {"typeKey": "running"},
|
||||||
|
"summaryDTO": {
|
||||||
|
"duration": 3600,
|
||||||
|
"distance": 10.0,
|
||||||
|
"maxHR": 180,
|
||||||
|
"calories": 400
|
||||||
|
}
|
||||||
|
}):
|
||||||
|
|
||||||
sync_database(mock_client)
|
sync_database(mock_client)
|
||||||
|
|
||||||
# Verify get_activities was called
|
# Verify activities processed
|
||||||
mock_client.get_activities.assert_called_once_with(0, 1000)
|
assert mock_session.add.call_count == 2
|
||||||
|
assert mock_session.commit.called
|
||||||
# Verify database operations
|
|
||||||
mock_session.return_value.add.assert_called()
|
|
||||||
mock_session.return_value.commit.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_database_with_none_activities():
|
def test_sync_database_with_none_activities():
|
||||||
"""Test sync_database with None response from API"""
|
"""Test sync_database with None response from API"""
|
||||||
mock_client = Mock(spec=GarminClient)
|
mock_client = Mock()
|
||||||
mock_client.get_activities.return_value = None
|
mock_client.get_activities.return_value = None
|
||||||
|
|
||||||
with patch('garminsync.database.get_session') as mock_session:
|
mock_session = MagicMock()
|
||||||
|
|
||||||
|
with patch('garminsync.database.get_session', return_value=mock_session):
|
||||||
sync_database(mock_client)
|
sync_database(mock_client)
|
||||||
|
mock_session.add.assert_not_called()
|
||||||
# Verify get_activities was called
|
|
||||||
mock_client.get_activities.assert_called_once_with(0, 1000)
|
|
||||||
|
|
||||||
# Verify no database operations
|
|
||||||
mock_session.return_value.add.assert_not_called()
|
|
||||||
mock_session.return_value.commit.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_database_with_missing_fields():
|
def test_sync_database_with_missing_fields():
|
||||||
"""Test sync_database with activities missing required fields"""
|
"""Test sync_database with activities missing required fields"""
|
||||||
mock_client = Mock(spec=GarminClient)
|
mock_client = Mock()
|
||||||
mock_client.get_activities.return_value = [
|
mock_client.get_activities.return_value = [
|
||||||
{"activityId": 12345}, # Missing startTimeLocal
|
{"activityId": 12345},
|
||||||
{"startTimeLocal": "2023-01-02T11:00:00"}, # Missing activityId
|
{"startTimeLocal": "2023-01-02T11:00:00"},
|
||||||
{"activityId": 67890, "startTimeLocal": "2023-01-03T12:00:00"} # Valid
|
{"activityId": 67890, "startTimeLocal": "2023-01-03T12:00:00"}
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('garminsync.database.get_session') as mock_session:
|
# Create a mock that returns None for existing activity
|
||||||
mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None
|
mock_session = MagicMock()
|
||||||
|
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||||
|
|
||||||
|
with patch('garminsync.database.get_session', return_value=mock_session), \
|
||||||
|
patch('garminsync.database.get_activity_metrics', return_value={
|
||||||
|
"summaryDTO": {"duration": 3600.0}
|
||||||
|
}):
|
||||||
sync_database(mock_client)
|
sync_database(mock_client)
|
||||||
|
# Only valid activity should be added
|
||||||
# Verify only one activity was added (the valid one)
|
assert mock_session.add.call_count == 1
|
||||||
assert mock_session.return_value.add.call_count == 1
|
added_activity = mock_session.add.call_args[0][0]
|
||||||
mock_session.return_value.commit.assert_called()
|
assert added_activity.activity_id == 67890
|
||||||
|
|
||||||
|
|
||||||
def test_sync_database_with_existing_activities():
|
def test_sync_database_with_existing_activities():
|
||||||
"""Test sync_database doesn't duplicate existing activities"""
|
"""Test sync_database doesn't duplicate existing activities"""
|
||||||
mock_client = Mock(spec=GarminClient)
|
mock_client = Mock()
|
||||||
mock_client.get_activities.return_value = [
|
mock_client.get_activities.return_value = [
|
||||||
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"}
|
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"}
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('garminsync.database.get_session') as mock_session:
|
mock_session = MagicMock()
|
||||||
# Mock existing activity
|
mock_session.query.return_value.filter_by.return_value.first.return_value = Mock()
|
||||||
mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = Mock()
|
|
||||||
|
with patch('garminsync.database.get_session', return_value=mock_session), \
|
||||||
|
patch('garminsync.database.get_activity_metrics', return_value={
|
||||||
|
"summaryDTO": {"duration": 3600.0}
|
||||||
|
}):
|
||||||
sync_database(mock_client)
|
sync_database(mock_client)
|
||||||
|
mock_session.add.assert_not_called()
|
||||||
# Verify no new activities were added
|
|
||||||
mock_session.return_value.add.assert_not_called()
|
|
||||||
mock_session.return_value.commit.assert_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_sync_database_with_invalid_activity_data():
|
def test_sync_database_with_invalid_activity_data():
|
||||||
"""Test sync_database with invalid activity data types"""
|
"""Test sync_database with invalid activity data types"""
|
||||||
mock_client = Mock(spec=GarminClient)
|
mock_client = Mock()
|
||||||
mock_client.get_activities.return_value = [
|
mock_client.get_activities.return_value = [
|
||||||
"invalid activity data", # Not a dict
|
"invalid data",
|
||||||
None, # None value
|
None,
|
||||||
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"} # Valid
|
{"activityId": 12345, "startTimeLocal": "2023-01-01T10:00:00"}
|
||||||
]
|
]
|
||||||
|
|
||||||
with patch('garminsync.database.get_session') as mock_session:
|
# Create a mock that returns None for existing activity
|
||||||
mock_session.return_value.query.return_value.filter_by.return_value.first.return_value = None
|
mock_session = MagicMock()
|
||||||
|
mock_session.query.return_value.filter_by.return_value.first.return_value = None
|
||||||
|
|
||||||
|
with patch('garminsync.database.get_session', return_value=mock_session), \
|
||||||
|
patch('garminsync.database.get_activity_metrics', return_value={
|
||||||
|
"summaryDTO": {"duration": 3600.0}
|
||||||
|
}):
|
||||||
sync_database(mock_client)
|
sync_database(mock_client)
|
||||||
|
# Only valid activity should be added
|
||||||
# Verify only one activity was added (the valid one)
|
assert mock_session.add.call_count == 1
|
||||||
assert mock_session.return_value.add.call_count == 1
|
added_activity = mock_session.add.call_args[0][0]
|
||||||
mock_session.return_value.commit.assert_called()
|
assert added_activity.activity_id == 12345
|
||||||
|
|||||||
100
workflows.md
Normal file
100
workflows.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# GarminSync Workflows
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
Add new columns to database and populate with activity metrics
|
||||||
|
|
||||||
|
### Trigger
|
||||||
|
`python cli.py migrate`
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. Add required columns to activities table:
|
||||||
|
- activity_type (TEXT)
|
||||||
|
- duration (INTEGER)
|
||||||
|
- distance (REAL)
|
||||||
|
- max_heart_rate (INTEGER)
|
||||||
|
- avg_power (REAL)
|
||||||
|
- calories (INTEGER)
|
||||||
|
2. For each activity:
|
||||||
|
- Parse metrics from local FIT/XML files
|
||||||
|
- Fetch from Garmin API if local files missing
|
||||||
|
- Update database fields
|
||||||
|
3. Commit changes
|
||||||
|
4. Report migration status
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
- Logs errors per activity
|
||||||
|
- Marks unprocessable activities as "Unknown"
|
||||||
|
- Continues processing other activities on error
|
||||||
|
|
||||||
|
## Sync Workflow
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
Keep local database synchronized with Garmin Connect
|
||||||
|
|
||||||
|
### Triggers
|
||||||
|
- CLI commands (`list`, `download`)
|
||||||
|
- Scheduled daemon (every 6 hours by default)
|
||||||
|
- Web UI requests
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
- `sync_database()`: Syncs activity metadata
|
||||||
|
- `download()`: Fetches missing FIT files
|
||||||
|
- Daemon: Background scheduler and web UI
|
||||||
|
|
||||||
|
### Process Flow
|
||||||
|
1. Authenticate with Garmin API
|
||||||
|
2. Fetch latest activities
|
||||||
|
3. For each activity:
|
||||||
|
- Parse metrics from FIT/XML files
|
||||||
|
- Fetch from Garmin API if local files missing
|
||||||
|
- Update database fields
|
||||||
|
4. Download missing activity files
|
||||||
|
5. Update sync timestamps
|
||||||
|
6. Log operations
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
activities {
|
||||||
|
integer activity_id PK
|
||||||
|
string start_time
|
||||||
|
string activity_type
|
||||||
|
integer duration
|
||||||
|
float distance
|
||||||
|
integer max_heart_rate
|
||||||
|
integer avg_heart_rate
|
||||||
|
float avg_power
|
||||||
|
integer calories
|
||||||
|
string filename
|
||||||
|
boolean downloaded
|
||||||
|
string created_at
|
||||||
|
string last_sync
|
||||||
|
}
|
||||||
|
|
||||||
|
daemon_config {
|
||||||
|
integer id PK
|
||||||
|
boolean enabled
|
||||||
|
string schedule_cron
|
||||||
|
string last_run
|
||||||
|
string next_run
|
||||||
|
string status
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_logs {
|
||||||
|
integer id PK
|
||||||
|
string timestamp
|
||||||
|
string operation
|
||||||
|
string status
|
||||||
|
string message
|
||||||
|
integer activities_processed
|
||||||
|
integer activities_downloaded
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Notes
|
||||||
|
- Data directory: `data/` (configurable via DATA_DIR)
|
||||||
|
- Web UI port: 8080 (default)
|
||||||
|
- Downloaded files: `activity_{id}_{timestamp}.fit`
|
||||||
|
- Metrics include: heart rate, power, calories, distance
|
||||||
Reference in New Issue
Block a user