mirror of
https://github.com/sstent/garminsync-go.git
synced 2025-12-06 08:01:52 +00:00
partital fix
This commit is contained in:
@@ -42,7 +42,7 @@ ENV TZ=UTC \
|
||||
DB_PATH=/data/garmin.db
|
||||
|
||||
# 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
|
||||
|
||||
# Run as non-root user
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
garminsync:
|
||||
build: .
|
||||
@@ -14,7 +12,8 @@ services:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- garmin-api
|
||||
garmin-api:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"]
|
||||
interval: 30s
|
||||
@@ -35,7 +34,8 @@ services:
|
||||
- "8081:8081"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8081/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8081/health | grep -q 'authenticated' || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
@@ -6,6 +6,11 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Final stage
|
||||
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
|
||||
|
||||
# Copy dependencies from builder stage
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from flask import Flask, request, jsonify, send_file
|
||||
import io
|
||||
from garminconnect import Garmin
|
||||
import logging
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -11,20 +12,33 @@ app = Flask(__name__)
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global API client
|
||||
api = None
|
||||
last_init_time = 0
|
||||
init_retry_interval = 60 # seconds
|
||||
|
||||
# Environment variables
|
||||
GARMIN_EMAIL = os.getenv("GARMIN_EMAIL")
|
||||
GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD")
|
||||
|
||||
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:
|
||||
api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD)
|
||||
api.login()
|
||||
new_api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD)
|
||||
new_api.login()
|
||||
api = new_api
|
||||
last_init_time = time.time()
|
||||
logger.info("Successfully authenticated with Garmin API")
|
||||
return api
|
||||
except Exception as 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'])
|
||||
def get_stats():
|
||||
@@ -100,8 +114,15 @@ def download_activity(activity_id):
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
return jsonify({"status": "healthy", "service": "garmin-api"})
|
||||
"""Health check endpoint with authentication status."""
|
||||
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__':
|
||||
app.run(host='0.0.0.0', port=8081)
|
||||
|
||||
@@ -1,156 +1,222 @@
|
||||
// internal/garmin/client.go
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
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 {
|
||||
ActivityID int `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
StartTimeLocal string `json:"startTimeLocal"`
|
||||
ActivityType map[string]interface{} `json:"activityType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
AvgHR float64 `json:"avgHR"`
|
||||
AvgPower float64 `json:"avgPower"`
|
||||
Calories float64 `json:"calories"`
|
||||
StartLatitude float64 `json:"startLatitude"`
|
||||
StartLongitude float64 `json:"startLongitude"`
|
||||
Steps float64 `json:"steps"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AvgTemperature float64 `json:"avgTemperature"`
|
||||
MinTemperature float64 `json:"minTemperature"`
|
||||
MaxTemperature float64 `json:"maxTemperature"`
|
||||
ActivityID int `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
StartTimeLocal string `json:"startTimeLocal"`
|
||||
ActivityType map[string]interface{} `json:"activityType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
AvgHR float64 `json:"avgHR"`
|
||||
AvgPower float64 `json:"avgPower"`
|
||||
Calories float64 `json:"calories"`
|
||||
StartLatitude float64 `json:"startLatitude"`
|
||||
StartLongitude float64 `json:"startLongitude"`
|
||||
Steps float64 `json:"steps"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AvgTemperature float64 `json:"avgTemperature"`
|
||||
MinTemperature float64 `json:"minTemperature"`
|
||||
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) {
|
||||
// Construct request URL
|
||||
url := fmt.Sprintf("%s/stats?date=%s", c.baseURL, url.QueryEscape(date))
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var stats map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
url := fmt.Sprintf("%s/stats?date=%s", c.baseURL, url.QueryEscape(date))
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
var bodyBytes []byte
|
||||
reqErr := error(nil)
|
||||
|
||||
for i := 0; i <= c.retries; i++ {
|
||||
resp, reqErr = c.httpClient.Do(req)
|
||||
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
|
||||
if i < c.retries {
|
||||
backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
|
||||
log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
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 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) {
|
||||
url := fmt.Sprintf("%s/activities?start=%d&limit=%d", c.baseURL, start, limit)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var activities []GarminActivity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
url := fmt.Sprintf("%s/activities?start=%d&limit=%d", c.baseURL, start, limit)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
var bodyBytes []byte
|
||||
reqErr := error(nil)
|
||||
|
||||
for i := 0; i <= c.retries; i++ {
|
||||
resp, reqErr = c.httpClient.Do(req)
|
||||
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
|
||||
if i < c.retries {
|
||||
backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
|
||||
log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
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) {
|
||||
url := fmt.Sprintf("%s/activities/%d/download?format=%s", c.baseURL, activityID, format)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
url := fmt.Sprintf("%s/activities/%d/download?format=%s", c.baseURL, activityID, format)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
reqErr := error(nil)
|
||||
|
||||
for i := 0; i <= c.retries; i++ {
|
||||
resp, reqErr = c.httpClient.Do(req)
|
||||
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
|
||||
if i < c.retries {
|
||||
backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
|
||||
log.Printf("Download failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
}
|
||||
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) {
|
||||
url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID)
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var activity GarminActivity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &activity, nil
|
||||
url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resp *http.Response
|
||||
var bodyBytes []byte
|
||||
reqErr := error(nil)
|
||||
|
||||
for i := 0; i <= c.retries; i++ {
|
||||
resp, reqErr = c.httpClient.Do(req)
|
||||
if reqErr != nil || (resp != nil && resp.StatusCode >= 500) {
|
||||
if i < c.retries {
|
||||
backoff := time.Duration(math.Pow(2, float64(i))) * time.Second
|
||||
log.Printf("Request failed (attempt %d/%d), retrying in %v: %v", i+1, c.retries, backoff, reqErr)
|
||||
time.Sleep(backoff)
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/sstent/garminsync-go/internal/database"
|
||||
"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 {
|
||||
fmt.Println("=== Starting full sync ===")
|
||||
defer fmt.Println("=== Sync completed ===")
|
||||
fmt.Println("=== Starting full sync ===")
|
||||
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
|
||||
email := os.Getenv("GARMIN_EMAIL")
|
||||
password := os.Getenv("GARMIN_PASSWORD")
|
||||
|
||||
if email == "" || password == "" {
|
||||
return fmt.Errorf("Missing credentials - GARMIN_EMAIL: '%s', GARMIN_PASSWORD: %s",
|
||||
email,
|
||||
map[bool]string{true: "SET", false: "EMPTY"}[password != ""])
|
||||
errorMsg := fmt.Sprintf("Missing credentials - GARMIN_EMAIL: '%s', GARMIN_PASSWORD: %s",
|
||||
email,
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user