partital fix

This commit is contained in:
2025-08-26 13:10:12 -07:00
parent 6f6dccfa7d
commit 984f28a7c2
6 changed files with 269 additions and 148 deletions

View File

@@ -42,7 +42,7 @@ ENV TZ=UTC \
DB_PATH=/data/garmin.db DB_PATH=/data/garmin.db
# Create data volume and set permissions # Create data volume and set permissions
RUN mkdir /data && chown nobody:nobody /data RUN mkdir -p /data/activities && chown -R nobody:nobody /data
VOLUME /data VOLUME /data
# Run as non-root user # Run as non-root user

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
garminsync: garminsync:
build: . build: .
@@ -14,7 +12,8 @@ services:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- garmin-api garmin-api:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"] test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
interval: 30s interval: 30s
@@ -35,7 +34,8 @@ services:
- "8081:8081" - "8081:8081"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8081/health"] test: ["CMD-SHELL", "curl -f http://localhost:8081/health | grep -q 'authenticated' || exit 1"]
interval: 30s interval: 10s
timeout: 10s timeout: 5s
retries: 3 retries: 10
start_period: 30s

View File

@@ -6,6 +6,11 @@ RUN pip install --no-cache-dir -r requirements.txt
# Final stage # Final stage
FROM python:3.12-slim FROM python:3.12-slim
# Install curl
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Copy dependencies from builder stage # Copy dependencies from builder stage

View File

@@ -1,9 +1,10 @@
import os import os
import json import json
import time
import logging
from flask import Flask, request, jsonify, send_file from flask import Flask, request, jsonify, send_file
import io import io
from garminconnect import Garmin from garminconnect import Garmin
import logging
app = Flask(__name__) app = Flask(__name__)
@@ -11,20 +12,33 @@ app = Flask(__name__)
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Global API client
api = None
last_init_time = 0
init_retry_interval = 60 # seconds
# Environment variables # Environment variables
GARMIN_EMAIL = os.getenv("GARMIN_EMAIL") GARMIN_EMAIL = os.getenv("GARMIN_EMAIL")
GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD") GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD")
def init_api(): def init_api():
"""Initializes the Garmin API client.""" """Initializes the Garmin API client (with retries)."""
global api, last_init_time
if api and time.time() - last_init_time < init_retry_interval:
return api
try: try:
api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD) new_api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD)
api.login() new_api.login()
api = new_api
last_init_time = time.time()
logger.info("Successfully authenticated with Garmin API") logger.info("Successfully authenticated with Garmin API")
return api return api
except Exception as e: except Exception as e:
logger.error(f"Error initializing Garmin API: {e}") logger.error(f"Error initializing Garmin API: {e}")
return None if not api:
logger.critical("Critical: API initialization failed")
return api
@app.route('/stats', methods=['GET']) @app.route('/stats', methods=['GET'])
def get_stats(): def get_stats():
@@ -100,8 +114,15 @@ def download_activity(activity_id):
@app.route('/health', methods=['GET']) @app.route('/health', methods=['GET'])
def health_check(): def health_check():
"""Health check endpoint.""" """Health check endpoint with authentication status."""
return jsonify({"status": "healthy", "service": "garmin-api"}) if api:
return jsonify({"status": "healthy", "auth_status": "authenticated", "service": "garmin-api"}), 200
else:
# Attempt to reinitialize if not tried recently
init_api()
if api:
return jsonify({"status": "healthy", "auth_status": "reauthenticated", "service": "garmin-api"}), 200
return jsonify({"status": "unhealthy", "auth_status": "unauthenticated", "service": "garmin-api"}), 503
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=8081) app.run(host='0.0.0.0', port=8081)

View File

@@ -1,156 +1,222 @@
// internal/garmin/client.go
package garmin package garmin
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"math"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
) )
type Client struct { type Client struct {
httpClient *http.Client httpClient *http.Client
baseURL string baseURL string
retries int // Number of retries for failed requests
}
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "http://garmin-api:8081",
retries: 3, // Default to 3 retries
}
} }
type GarminActivity struct { type GarminActivity struct {
ActivityID int `json:"activityId"` ActivityID int `json:"activityId"`
ActivityName string `json:"activityName"` ActivityName string `json:"activityName"`
StartTimeLocal string `json:"startTimeLocal"` StartTimeLocal string `json:"startTimeLocal"`
ActivityType map[string]interface{} `json:"activityType"` ActivityType map[string]interface{} `json:"activityType"`
Distance float64 `json:"distance"` Distance float64 `json:"distance"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
MaxHR float64 `json:"maxHR"` MaxHR float64 `json:"maxHR"`
AvgHR float64 `json:"avgHR"` AvgHR float64 `json:"avgHR"`
AvgPower float64 `json:"avgPower"` AvgPower float64 `json:"avgPower"`
Calories float64 `json:"calories"` Calories float64 `json:"calories"`
StartLatitude float64 `json:"startLatitude"` StartLatitude float64 `json:"startLatitude"`
StartLongitude float64 `json:"startLongitude"` StartLongitude float64 `json:"startLongitude"`
Steps float64 `json:"steps"` Steps float64 `json:"steps"`
ElevationGain float64 `json:"elevationGain"` ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"` ElevationLoss float64 `json:"elevationLoss"`
AvgTemperature float64 `json:"avgTemperature"` AvgTemperature float64 `json:"avgTemperature"`
MinTemperature float64 `json:"minTemperature"` MinTemperature float64 `json:"minTemperature"`
MaxTemperature float64 `json:"maxTemperature"` MaxTemperature float64 `json:"maxTemperature"`
} }
// NewClient creates a new Garmin API client
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "http://garmin-api:8081",
}
}
// GetStats retrieves user statistics for a specific date via the Python API service
func (c *Client) GetStats(date string) (map[string]interface{}, error) { func (c *Client) GetStats(date string) (map[string]interface{}, error) {
// Construct request URL url := fmt.Sprintf("%s/stats?date=%s", c.baseURL, url.QueryEscape(date))
url := fmt.Sprintf("%s/stats?date=%s", c.baseURL, url.QueryEscape(date)) req, err := http.NewRequest("GET", url, nil)
if err != nil {
req, err := http.NewRequest("GET", url, nil) return nil, err
if err != nil { }
return nil, err
} var resp *http.Response
var bodyBytes []byte
resp, err := c.httpClient.Do(req) reqErr := error(nil)
if err != nil {
return nil, err for i := 0; i <= c.retries; i++ {
} resp, reqErr = c.httpClient.Do(req)
defer resp.Body.Close() if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
if i < c.retries {
if resp.StatusCode != http.StatusOK { backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
body, _ := io.ReadAll(resp.Body) log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) time.Sleep(backoff)
} continue
}
var stats map[string]interface{} }
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { break
return nil, err }
}
if reqErr != nil {
return stats, nil return nil, fmt.Errorf("request failed after %d retries: %w", c.retries, reqErr)
}
defer resp.Body.Close()
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, bodyBytes)
}
var stats map[string]interface{}
if jsonErr := json.Unmarshal(bodyBytes, &stats); jsonErr != nil {
return nil, jsonErr
}
return stats, nil
} }
// GetActivities retrieves activities from the Python API wrapper
func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) { func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) {
url := fmt.Sprintf("%s/activities?start=%d&limit=%d", c.baseURL, start, limit) url := fmt.Sprintf("%s/activities?start=%d&limit=%d", c.baseURL, start, limit)
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, nil) if err != nil {
if err != nil { return nil, err
return nil, err }
}
var resp *http.Response
resp, err := c.httpClient.Do(req) var bodyBytes []byte
if err != nil { reqErr := error(nil)
return nil, err
} for i := 0; i <= c.retries; i++ {
defer resp.Body.Close() resp, reqErr = c.httpClient.Do(req)
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
if resp.StatusCode != http.StatusOK { if i < c.retries {
body, _ := io.ReadAll(resp.Body) backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
} time.Sleep(backoff)
continue
var activities []GarminActivity }
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { }
return nil, err break
} }
return activities, nil if reqErr != nil {
return nil, fmt.Errorf("request failed after %d retries: %w", c.retries, reqErr)
}
defer resp.Body.Close()
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, bodyBytes)
}
var activities []GarminActivity
if jsonErr := json.Unmarshal(bodyBytes, &activities); jsonErr != nil {
return nil, jsonErr
}
return activities, nil
} }
// Helper function removed - no longer needed
// DownloadActivity downloads an activity via the Python API wrapper
func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) { func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) {
url := fmt.Sprintf("%s/activities/%d/download?format=%s", c.baseURL, activityID, format) url := fmt.Sprintf("%s/activities/%d/download?format=%s", c.baseURL, activityID, format)
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, nil) if err != nil {
if err != nil { return nil, err
return nil, err }
}
var resp *http.Response
resp, err := c.httpClient.Do(req) reqErr := error(nil)
if err != nil {
return nil, err for i := 0; i <= c.retries; i++ {
} resp, reqErr = c.httpClient.Do(req)
defer resp.Body.Close() if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
if i < c.retries {
if resp.StatusCode != http.StatusOK { backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
return nil, fmt.Errorf("API returned status %d", resp.StatusCode) log.Printf("Download failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
} time.Sleep(backoff)
continue
return io.ReadAll(resp.Body) }
}
break
}
if reqErr != nil {
return nil, fmt.Errorf("download failed after %d retries: %w", c.retries, reqErr)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
} }
// GetActivityDetails retrieves details for a specific activity from the Python API wrapper
func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID) url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID)
req, err := http.NewRequest("GET", url, nil)
req, err := http.NewRequest("GET", url, nil) if err != nil {
if err != nil { return nil, err
return nil, err }
}
var resp *http.Response
resp, err := c.httpClient.Do(req) var bodyBytes []byte
if err != nil { reqErr := error(nil)
return nil, err
} for i := 0; i <= c.retries; i++ {
defer resp.Body.Close() resp, reqErr = c.httpClient.Do(req)
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
if resp.StatusCode != http.StatusOK { if i < c.retries {
body, _ := io.ReadAll(resp.Body) backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
} time.Sleep(backoff)
continue
var activity GarminActivity }
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil { }
return nil, err break
} }
return &activity, nil if reqErr != nil {
return nil, fmt.Errorf("request failed after %d retries: %w", c.retries, reqErr)
}
defer resp.Body.Close()
bodyBytes, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("failed to read response body: %w", readErr)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, bodyBytes)
}
var activity GarminActivity
if jsonErr := json.Unmarshal(bodyBytes, &activity); jsonErr != nil {
return nil, jsonErr
}
return &activity, nil
} }

View File

@@ -6,6 +6,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"strings"
"github.com/sstent/garminsync-go/internal/database" "github.com/sstent/garminsync-go/internal/database"
"github.com/sstent/garminsync-go/internal/garmin" "github.com/sstent/garminsync-go/internal/garmin"
@@ -26,18 +27,46 @@ func NewSyncService(garminClient *garmin.Client, db *database.SQLiteDB, dataDir
} }
} }
func (s *SyncService) testAPIConnectivity() error {
// Try a simple API call to check connectivity
_, err := s.garminClient.GetActivities(0, 1)
if err != nil {
// Analyze error for troubleshooting hints
if strings.Contains(err.Error(), "connection refused") {
return fmt.Errorf("API connection failed: service might not be running. Verify garmin-api container is up. Original error: %w", err)
} else if strings.Contains(err.Error(), "timeout") {
return fmt.Errorf("API connection timeout: service might be slow to start. Original error: %w", err)
} else if strings.Contains(err.Error(), "status 5") {
return fmt.Errorf("API server error: check garmin-api logs. Original error: %w", err)
}
return fmt.Errorf("API connectivity test failed: %w", err)
}
return nil
}
func (s *SyncService) FullSync(ctx context.Context) error { func (s *SyncService) FullSync(ctx context.Context) error {
fmt.Println("=== Starting full sync ===") fmt.Println("=== Starting full sync ===")
defer fmt.Println("=== Sync completed ===") defer fmt.Println("=== Sync completed ===")
// Check API connectivity before proceeding
if err := s.testAPIConnectivity(); err != nil {
return fmt.Errorf("API connectivity test failed: %w", err)
}
fmt.Println("✅ API connectivity verified")
// Check credentials first // Check credentials first
email := os.Getenv("GARMIN_EMAIL") email := os.Getenv("GARMIN_EMAIL")
password := os.Getenv("GARMIN_PASSWORD") password := os.Getenv("GARMIN_PASSWORD")
if email == "" || password == "" { if email == "" || password == "" {
return fmt.Errorf("Missing credentials - GARMIN_EMAIL: '%s', GARMIN_PASSWORD: %s", errorMsg := fmt.Sprintf("Missing credentials - GARMIN_EMAIL: '%s', GARMIN_PASSWORD: %s",
email, email,
map[bool]string{true: "SET", false: "EMPTY"}[password != ""]) map[bool]string{true: "SET", false: "EMPTY"}[password != ""])
errorMsg += "\nTroubleshooting:"
errorMsg += "\n1. Ensure the .env file exists with GARMIN_EMAIL and GARMIN_PASSWORD"
errorMsg += "\n2. Verify docker-compose.yml mounts the .env file"
errorMsg += "\n3. Check container env vars: docker-compose exec garminsync env | grep GARMIN"
return fmt.Errorf(errorMsg)
} }
fmt.Printf("Using credentials - Email: %s, Password: %s\n", email, fmt.Printf("Using credentials - Email: %s, Password: %s\n", email,