From 0a5076f7bbb6684fd1274ce7f94deafec0c70568 Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 26 Aug 2025 10:29:42 -0700 Subject: [PATCH] moved to python for garmin api and golang for everything else --- docker-compose.yml | 20 +- garmin-api-wrapper/Dockerfile | 25 ++ garmin-api-wrapper/app.py | 86 ++++++ garmin-api-wrapper/requirements.txt | 3 + garmin-api-wrapper/test_api.py | 69 +++++ internal/garmin/client.go | 418 ++++------------------------ test_login.go | 36 +++ 7 files changed, 289 insertions(+), 368 deletions(-) create mode 100644 garmin-api-wrapper/Dockerfile create mode 100644 garmin-api-wrapper/app.py create mode 100644 garmin-api-wrapper/requirements.txt create mode 100644 garmin-api-wrapper/test_api.py create mode 100644 test_login.go diff --git a/docker-compose.yml b/docker-compose.yml index 9a06e13..842d8a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,19 +7,35 @@ services: ports: - "8888:8888" env_file: - - .env # Use the root .env file + - .env volumes: - ./data:/data - ./internal/web/templates:/app/internal/web/templates - /etc/localtime:/etc/localtime:ro restart: unless-stopped + depends_on: + - garmin-api healthcheck: test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8888/health"] interval: 30s - timeout: 30s # Increased timeout for startup + timeout: 30s retries: 3 logging: driver: "json-file" options: max-size: "10m" max-file: "3" + + garmin-api: + build: ./garmin-api-wrapper + container_name: garmin-api + env_file: + - .env + ports: + - "8081:8081" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/garmin-api-wrapper/Dockerfile b/garmin-api-wrapper/Dockerfile new file mode 100644 index 0000000..9f06440 --- /dev/null +++ b/garmin-api-wrapper/Dockerfile @@ -0,0 +1,25 @@ +# Builder stage +FROM python:3.12-slim AS builder +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Final stage +FROM python:3.12-slim +WORKDIR /app + +# Copy dependencies from builder stage +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy application code +COPY app.py . + +# Add user for security +RUN groupadd -r appuser && \ + useradd -r -g appuser appuser && \ + chown -R appuser:appuser /app +USER appuser + +EXPOSE 8081 +CMD ["python", "app.py"] diff --git a/garmin-api-wrapper/app.py b/garmin-api-wrapper/app.py new file mode 100644 index 0000000..c374b3e --- /dev/null +++ b/garmin-api-wrapper/app.py @@ -0,0 +1,86 @@ +import os +import json +from flask import Flask, request, jsonify +from garminconnect import Garmin +import logging + +app = Flask(__name__) + +# Setup logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s') +logger = logging.getLogger(__name__) + +# Environment variables +GARMIN_EMAIL = os.getenv("GARMIN_EMAIL") +GARMIN_PASSWORD = os.getenv("GARMIN_PASSWORD") + +def init_api(): + """Initializes the Garmin API client.""" + try: + api = Garmin(GARMIN_EMAIL, GARMIN_PASSWORD) + api.login() + logger.info("Successfully authenticated with Garmin API") + return api + except Exception as e: + logger.error(f"Error initializing Garmin API: {e}") + return None + +@app.route('/stats', methods=['GET']) +def get_stats(): + """Endpoint to get user stats.""" + stats_date = request.args.get('date') + if not stats_date: + return jsonify({"error": "A 'date' query parameter is required in YYYY-MM-DD format."}), 400 + + api = init_api() + if not api: + return jsonify({"error": "Failed to connect to Garmin API"}), 500 + + try: + logger.info(f"Fetching stats for date: {stats_date}") + user_stats = api.get_stats(stats_date) + return jsonify(user_stats) + except Exception as e: + logger.error(f"Error fetching stats: {str(e)}") + return jsonify({"error": str(e)}), 500 + +@app.route('/activities', methods=['GET']) +def get_activities(): + """Endpoint to get activities list.""" + start = request.args.get('start', default=0, type=int) + limit = request.args.get('limit', default=10, type=int) + + api = init_api() + if not api: + return jsonify({"error": "Failed to connect to Garmin API"}), 500 + + try: + logger.info(f"Fetching activities from {start} with limit {limit}") + activities = api.get_activities(start, limit) + return jsonify(activities) + except Exception as e: + logger.error(f"Error fetching activities: {str(e)}") + return jsonify({"error": str(e)}), 500 + +@app.route('/activities/', methods=['GET']) +def get_activity_details(activity_id): + """Endpoint to get activity details.""" + api = init_api() + if not api: + return jsonify({"error": "Failed to connect to Garmin API"}), 500 + + try: + logger.info(f"Fetching activity details for {activity_id}") + activity = api.get_activity(activity_id) + return jsonify(activity) + except Exception as e: + logger.error(f"Error fetching activity details: {str(e)}") + return jsonify({"error": str(e)}), 500 + +@app.route('/health', methods=['GET']) +def health_check(): + """Health check endpoint.""" + return jsonify({"status": "healthy", "service": "garmin-api"}) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8081) diff --git a/garmin-api-wrapper/requirements.txt b/garmin-api-wrapper/requirements.txt new file mode 100644 index 0000000..2e2149f --- /dev/null +++ b/garmin-api-wrapper/requirements.txt @@ -0,0 +1,3 @@ +garminconnect==0.2.28 +garth +Flask diff --git a/garmin-api-wrapper/test_api.py b/garmin-api-wrapper/test_api.py new file mode 100644 index 0000000..88aee27 --- /dev/null +++ b/garmin-api-wrapper/test_api.py @@ -0,0 +1,69 @@ +import os +import json +from garminconnect import Garmin +import logging +from datetime import datetime + +def main(): + # Setup logging + logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s: %(message)s') + logger = logging.getLogger(__name__) + + print("=== Starting Garmin API Tests ===") + + # Load credentials from environment + email = os.getenv("GARMIN_EMAIL") + password = os.getenv("GARMIN_PASSWORD") + + if not email or not password: + logger.error("GARMIN_EMAIL or GARMIN_PASSWORD environment variables not set") + return + + try: + # 1. Test Authentication + logger.info("Testing authentication...") + api = Garmin(email, password) + api.login() + logger.info("✅ Authentication successful") + + # 2. Test Activity Listing + logger.info("Testing activity listing...") + activities = api.get_activities(0, 1) # Get 1 most recent activity + if not activities: + logger.error("❌ No activities found") + else: + logger.info(f"✅ Found {len(activities)} activities") + print("Sample activity:") + print(json.dumps(activities[0], indent=2)[:1000]) # Print first 1000 chars + + # 3. Test Activity Download (if we got any activities) + if activities: + logger.info("Testing activity download...") + activity_id = activities[0]["activityId"] + details = api.get_activity(activity_id) + if details: + logger.info(f"✅ Activity {activity_id} details retrieved") + print("Sample details:") + print(json.dumps(details, indent=2)[:1000]) # Print first 1000 chars + else: + logger.error("❌ Failed to get activity details") + + # 4. Test Stats Retrieval + logger.info("Testing stats retrieval...") + stats = api.get_stats(datetime.now().strftime("%Y-%m-%d")) + if stats: + logger.info("✅ Stats retrieved") + print(json.dumps(stats, indent=2)[:1000]) + else: + logger.error("❌ Failed to get stats") + + except Exception as e: + logger.error(f"❌ Test failed: {str(e)}") + # Print detailed exception info + import traceback + traceback.print_exc() + + print("\n=== Test Complete ===") + +if __name__ == "__main__": + main() diff --git a/internal/garmin/client.go b/internal/garmin/client.go index a200d2f..4eccd2f 100644 --- a/internal/garmin/client.go +++ b/internal/garmin/client.go @@ -5,26 +5,14 @@ import ( "encoding/json" "fmt" "io" - "math/rand" "net/http" "net/url" - "os" - "strings" "time" ) type Client struct { httpClient *http.Client baseURL string - session *Session -} - -type Session struct { - Username string - Password string - Cookies []*http.Cookie - UserAgent string - Authenticated bool } type GarminActivity struct { @@ -34,13 +22,13 @@ type GarminActivity struct { ActivityType map[string]interface{} `json:"activityType"` Distance float64 `json:"distance"` Duration float64 `json:"duration"` - MaxHR int `json:"maxHR"` - AvgHR int `json:"avgHR"` + MaxHR float64 `json:"maxHR"` + AvgHR float64 `json:"avgHR"` AvgPower float64 `json:"avgPower"` - Calories int `json:"calories"` + Calories float64 `json:"calories"` StartLatitude float64 `json:"startLatitude"` StartLongitude float64 `json:"startLongitude"` - Steps int `json:"steps"` + Steps float64 `json:"steps"` ElevationGain float64 `json:"elevationGain"` ElevationLoss float64 `json:"elevationLoss"` AvgTemperature float64 `json:"avgTemperature"` @@ -48,381 +36,89 @@ type GarminActivity struct { MaxTemperature float64 `json:"maxTemperature"` } +// NewClient creates a new Garmin API client func NewClient() *Client { return &Client{ httpClient: &http.Client{ Timeout: 30 * time.Second, - Jar: nil, // Don't use cookie jar, we'll manage cookies manually - }, - baseURL: "https://connect.garmin.com", - session: &Session{ - Username: os.Getenv("GARMIN_EMAIL"), - Password: os.Getenv("GARMIN_PASSWORD"), - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", }, + baseURL: "http://garmin-api:8081", } } -func (c *Client) Login() error { - if c.session.Username == "" || c.session.Password == "" { - return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required") - } +// 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)) - fmt.Printf("DEBUG: Attempting login for user: %s\n", c.session.Username) - - // Add random delay to look more human - time.Sleep(time.Duration(rand.Intn(1500)+1000) * time.Millisecond) - - // Step 1: Get the initial login page to establish session - loginURL := "https://connect.garmin.com/signin/" - req, err := http.NewRequest("GET", loginURL, nil) + req, err := http.NewRequest("GET", url, nil) if err != nil { - return err + return nil, err } - req.Header.Set("User-Agent", c.session.UserAgent) - resp, err := c.httpClient.Do(req) if err != nil { - return fmt.Errorf("failed to get login page: %w", err) + return nil, err } defer resp.Body.Close() - fmt.Printf("DEBUG: Initial login page status: %d\n", resp.StatusCode) - - // Store cookies - c.session.Cookies = resp.Cookies() - fmt.Printf("DEBUG: Received %d cookies from login page\n", len(c.session.Cookies)) - - // Step 2: Submit login credentials - loginData := url.Values{} - loginData.Set("username", c.session.Username) - loginData.Set("password", c.session.Password) - loginData.Set("embed", "false") - loginData.Set("displayNameRequired", "false") - - // Add another delay before POST - time.Sleep(time.Duration(rand.Intn(1500)+1000) * time.Millisecond) - - req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode())) - if err != nil { - return err + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, body) } - // Add extra headers - req.Header.Set("Accept-Encoding", "gzip, deflate, br") - req.Header.Set("Connection", "keep-alive") - req.Header.Set("Pragma", "no-cache") - req.Header.Set("Cache-Control", "no-cache") - req.Header.Set("Upgrade-Insecure-Requests", "1") - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("User-Agent", c.session.UserAgent) - req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") - req.Header.Set("Accept-Language", "en-US,en;q=0.9") - req.Header.Set("Origin", "https://sso.garmin.com") - req.Header.Set("Referer", loginURL) - req.Header.Set("X-Requested-With", "XMLHttpRequest") - - // Add existing cookies - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) + var stats map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil { + return nil, err } - resp, err = c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to submit login: %w", err) - } - defer resp.Body.Close() - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - fmt.Printf("DEBUG: Login response status: %d\n", resp.StatusCode) - fmt.Printf("DEBUG: Login response body: %s\n", string(bodyBytes)) - - // Update cookies with login response - for _, cookie := range resp.Cookies() { - c.session.Cookies = append(c.session.Cookies, cookie) - } - - // Check for successful login indicators - bodyStr := string(bodyBytes) - if strings.Contains(bodyStr, "error") || strings.Contains(bodyStr, "invalid") { - return fmt.Errorf("login failed: %s", bodyStr) - } - - // Step 3: Get the Garmin Connect session - connectURL := "https://connect.garmin.com/modern/" - req, err = http.NewRequest("GET", connectURL, nil) - if err != nil { - return err - } - - req.Header.Set("User-Agent", c.session.UserAgent) - - // Add all cookies - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - - resp, err = c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to access Garmin Connect: %w", err) - } - defer resp.Body.Close() - - fmt.Printf("DEBUG: Garmin Connect access status: %d\n", resp.StatusCode) - - // Update cookies again - for _, cookie := range resp.Cookies() { - c.session.Cookies = append(c.session.Cookies, cookie) - } - - fmt.Printf("DEBUG: Total cookies after login: %d\n", len(c.session.Cookies)) - - if resp.StatusCode == http.StatusOK { - c.session.Authenticated = true - fmt.Println("DEBUG: Login successful!") - return nil - } - - return fmt.Errorf("login failed with status: %d", resp.StatusCode) + return stats, nil } +// GetActivities retrieves activities from the Python API wrapper func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) { - if !c.session.Authenticated { - if err := c.Login(); err != nil { - return nil, err - } - } - - url := fmt.Sprintf("%s/modern/proxy/activity-service/activities/search/activities?start=%d&limit=%d", - c.baseURL, start, limit) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", c.session.UserAgent) - req.Header.Set("Accept", "application/json") - - // Add cookies - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - - // Log cookies being sent - fmt.Println("DEBUG: Cookies being sent:") - for _, cookie := range req.Cookies() { - fmt.Printf(" %s: %s (Expires: %s)\n", - cookie.Name, - cookie.Value[:min(3, len(cookie.Value))] + "***", - cookie.Expires.Format(time.RFC1123)) - - // Check if cookie is expired - if !cookie.Expires.IsZero() && cookie.Expires.Before(time.Now()) { - fmt.Printf("WARNING: Cookie %s expired at %s\n", - cookie.Name, - cookie.Expires.Format(time.RFC1123)) - } - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - fmt.Printf("DEBUG: HTTP Status: %d\n", resp.StatusCode) - fmt.Printf("DEBUG: Response Headers: %v\n", resp.Header) - - // If we get empty response but 200 status, check session expiration - if resp.StatusCode == http.StatusOK && resp.ContentLength == 2 { - fmt.Println("WARNING: Empty API response with 200 status - checking session validity") - c.session.Authenticated = false - } - - bodyBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // Log full response for debugging - fmt.Printf("DEBUG: Full API Response (%d bytes):\n", len(bodyBytes)) - fmt.Println(string(bodyBytes)) - - // Check for empty response - if len(bodyBytes) == 0 { - return nil, fmt.Errorf("API returned empty response") - } - - // Special case for empty object - if string(bodyBytes) == "{}" { - fmt.Println("DEBUG: API returned empty object") - return nil, fmt.Errorf("API returned empty object") - } - - // Try flexible parsing - activities, err := parseActivityResponse(bodyBytes) - if err != nil { - fmt.Printf("DEBUG: Failed to parse activities: %v\n", err) - return nil, err - } - - fmt.Printf("DEBUG: Successfully parsed %d activities\n", len(activities)) - - // Rate limiting - time.Sleep(2 * time.Second) - - 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 + } + + 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 } -// Helper function -func min(a, b int) int { - if a < b { - return a - } - return b -} - -// parseActivityResponse handles different API response formats -func parseActivityResponse(bodyBytes []byte) ([]GarminActivity, error) { - // Try standard ActivityList format - type ActivityListResponse struct { - ActivityList []GarminActivity `json:"activityList"` - } - var listResponse ActivityListResponse - if err := json.Unmarshal(bodyBytes, &listResponse); err == nil && len(listResponse.ActivityList) > 0 { - return listResponse.ActivityList, nil - } - - // Try direct array format - var directResponse []GarminActivity - if err := json.Unmarshal(bodyBytes, &directResponse); err == nil && len(directResponse) > 0 { - return directResponse, nil - } - - // Try generic map-based format - var genericResponse map[string]interface{} - if err := json.Unmarshal(bodyBytes, &genericResponse); err == nil { - // Check if we have an "activityList" key - if activityList, ok := genericResponse["activityList"].([]interface{}); ok { - return convertInterfaceSlice(activityList) - } - // Check if we have a "results" key - if results, ok := genericResponse["results"].([]interface{}); ok { - return convertInterfaceSlice(results) - } - // Check if we have an "activities" key - if activities, ok := genericResponse["activities"].([]interface{}); ok { - return convertInterfaceSlice(activities) - } - } - - // Failed to parse - return nil, fmt.Errorf("unable to parse API response") -} - -// convertInterfaceSlice converts []interface{} to []GarminActivity -func convertInterfaceSlice(items []interface{}) ([]GarminActivity, error) { - var activities []GarminActivity - for _, item := range items { - itemMap, ok := item.(map[string]interface{}) - if !ok { - continue - } - - // Convert map to JSON then to GarminActivity - jsonData, err := json.Marshal(itemMap) - if err != nil { - return nil, err - } - - var activity GarminActivity - if err := json.Unmarshal(jsonData, &activity); err != nil { - return nil, err - } - - activities = append(activities, activity) - } - return activities, nil -} +// Helper function removed - no longer needed +// DownloadActivity downloads an activity from Garmin Connect (stub implementation) func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) { - if !c.session.Authenticated { - if err := c.Login(); err != nil { - return nil, err - } - } - - // Default to FIT format - if format == "" { - format = "fit" - } - - url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d", - c.baseURL, format, activityID) - - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("User-Agent", c.session.UserAgent) - - // Add cookies - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - - 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("failed to download activity %d: status %d", activityID, resp.StatusCode) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - // Rate limiting - time.Sleep(2 * time.Second) - - return data, nil + return nil, fmt.Errorf("DownloadActivity not implemented - use Python API") } +// GetActivityDetails retrieves details for a specific activity from the Python API wrapper func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { - if !c.session.Authenticated { - if err := c.Login(); err != nil { - return nil, err - } - } - - url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d", - c.baseURL, activityID) + url := fmt.Sprintf("%s/activities/%d", c.baseURL, activityID) req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } - req.Header.Set("User-Agent", c.session.UserAgent) - req.Header.Set("Accept", "application/json") - - // Add cookies - for _, cookie := range c.session.Cookies { - req.AddCookie(cookie) - } - resp, err := c.httpClient.Do(req) if err != nil { return nil, err @@ -430,24 +126,14 @@ func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode) + 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 } - - // Extract activity type from map if possible - if typeKey, ok := activity.ActivityType["typeKey"].(string); ok { - activity.ActivityType = map[string]interface{}{"typeKey": typeKey} - } else { - // Default to empty map if typeKey not found - activity.ActivityType = map[string]interface{}{} - } - - // Rate limiting - time.Sleep(2 * time.Second) return &activity, nil } diff --git a/test_login.go b/test_login.go new file mode 100644 index 0000000..28304b1 --- /dev/null +++ b/test_login.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "os" + "time" + + "github.com/sst/garmin-sync-go/internal/garmin" +) + +func main() { + // Set environment variables for testing + os.Setenv("GARMIN_EMAIL", "your_email@example.com") + os.Setenv("GARMIN_PASSWORD", "your_password") + + client := garmin.NewClient() + + start := time.Now() + fmt.Println("Starting login test...") + + err := client.Login() + if err != nil { + fmt.Printf("Login failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Login successful! Duration: %v\n", time.Since(start)) + + if client.IsAuthenticated() { + fmt.Println("Session authenticated:", client.IsAuthenticated()) + fmt.Println("Number of cookies:", len(client.GetCookies())) + } else { + fmt.Println("Session not authenticated after successful login") + os.Exit(1) + } +}