From ed815b50d2e9aa35dd01ee56f492f0a7a08affa9 Mon Sep 17 00:00:00 2001 From: sstent Date: Mon, 22 Sep 2025 18:14:11 -0700 Subject: [PATCH] reworked api interfaces --- README.md | 98 ++++++++++--- pkg/garmin/client.go | 93 +++++++++++-- pkg/garth/client/client.go | 277 ++++++++++++++++++++++++++++++++++--- 3 files changed, 423 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 0052ef5..5af4fee 100644 --- a/README.md +++ b/README.md @@ -25,35 +25,101 @@ func main() { if err != nil { panic(err) } - + err = client.Login("your@email.com", "password") if err != nil { panic(err) } - // Get yesterday's body battery data (detailed) - yesterday := time.Now().AddDate(0, 0, -1) - bb, err := client.GetBodyBatteryData(yesterday) + // List recent activities with filtering + opts := garmin.ActivityOptions{ + Limit: 10, + Offset: 0, + ActivityType: "running", // optional filter + DateFrom: time.Now().AddDate(0, 0, -30), // last 30 days + DateTo: time.Now(), + } + activities, err := client.ListActivities(opts) if err != nil { panic(err) } - - if bb != nil { - fmt.Printf("Body Battery: %d\n", bb.BodyBatteryValue) - } - // Get weekly steps - steps := garmin.NewDailySteps() - stepData, err := steps.List(time.Now(), 7, client) + for _, activity := range activities { + fmt.Printf("%s: %s (%.2f km)\n", + activity.StartTimeLocal.Format("2006-01-02"), + activity.ActivityName, + activity.Distance/1000) + } + + // Get detailed activity information + if len(activities) > 0 { + activityDetail, err := client.GetActivity(activities[0].ActivityID) + if err != nil { + panic(err) + } + fmt.Printf("Activity details: %+v\n", activityDetail) + } + + // Search for activities + searchResults, err := client.SearchActivities("morning run") if err != nil { panic(err) } - - for _, s := range stepData { - fmt.Printf("%s: %d steps\n", - s.(garmin.DailySteps).CalendarDate.Format("2006-01-02"), - *s.(garmin.DailySteps).TotalSteps) + fmt.Printf("Found %d activities matching search\n", len(searchResults)) + + // Get fitness age + fitnessAge, err := client.GetFitnessAge() + if err != nil { + panic(err) } + fmt.Printf("Fitness Age: %d (Chronological: %d)\n", + fitnessAge.FitnessAge, fitnessAge.ChronologicalAge) + + // Get health data ranges + start := time.Now().AddDate(0, 0, -7) + end := time.Now() + + sleepData, err := client.GetSleepData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Sleep records: %d\n", len(sleepData)) + + hrvData, err := client.GetHrvData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("HRV records: %d\n", len(hrvData)) + + stressData, err := client.GetStressData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Stress records: %d\n", len(stressData)) + + bodyBatteryData, err := client.GetBodyBatteryData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Body Battery records: %d\n", len(bodyBatteryData)) + + stepsData, err := client.GetStepsData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Steps records: %d\n", len(stepsData)) + + distanceData, err := client.GetDistanceData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Distance records: %d\n", len(distanceData)) + + caloriesData, err := client.GetCaloriesData(start, end) + if err != nil { + panic(err) + } + fmt.Printf("Calories records: %d\n", len(caloriesData)) } ``` diff --git a/pkg/garmin/client.go b/pkg/garmin/client.go index 4f5fbbe..0b90123 100644 --- a/pkg/garmin/client.go +++ b/pkg/garmin/client.go @@ -1,6 +1,7 @@ package garmin import ( + "encoding/json" "fmt" "io" "net/url" @@ -8,8 +9,9 @@ import ( "path/filepath" "time" - internalClient "github.com/sstent/go-garth/pkg/garth/client" "github.com/sstent/go-garth/internal/errors" + internalClient "github.com/sstent/go-garth/pkg/garth/client" + garth "github.com/sstent/go-garth/pkg/garth/types" shared "github.com/sstent/go-garth/shared/interfaces" models "github.com/sstent/go-garth/shared/models" ) @@ -81,9 +83,7 @@ func (c *Client) RefreshSession() error { // ListActivities retrieves recent activities func (c *Client) ListActivities(opts ActivityOptions) ([]Activity, error) { - // TODO: Map ActivityOptions to internalClient.Client.GetActivities parameters - // For now, just call the internal client's GetActivities with a dummy limit - internalActivities, err := c.Client.GetActivities(opts.Limit) + internalActivities, err := c.Client.GetActivitiesWithOptions(opts.Limit, opts.Offset, opts.ActivityType, opts.DateFrom, opts.DateTo) if err != nil { return nil, err } @@ -104,8 +104,33 @@ func (c *Client) ListActivities(opts ActivityOptions) ([]Activity, error) { // GetActivity retrieves details for a specific activity ID func (c *Client) GetActivity(activityID int) (*ActivityDetail, error) { - // TODO: Implement internalClient.Client.GetActivity - return nil, fmt.Errorf("not implemented") + path := fmt.Sprintf("/activity-service/activity/%d", activityID) + + data, err := c.Client.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get activity details: %w", err) + } + + if len(data) == 0 { + return nil, fmt.Errorf("activity not found") + } + + var activity garth.Activity + if err := json.Unmarshal(data, &activity); err != nil { + return nil, fmt.Errorf("failed to parse activity response: %w", err) + } + + return &ActivityDetail{ + Activity: Activity{ + ActivityID: activity.ActivityID, + ActivityName: activity.ActivityName, + ActivityType: ActivityType(activity.ActivityType), + StartTimeLocal: activity.StartTimeLocal, + Distance: activity.Distance, + Duration: activity.Duration, + }, + Description: activity.Description, + }, nil } // DownloadActivity downloads activity data @@ -162,8 +187,37 @@ func (c *Client) DownloadActivity(activityID int, opts DownloadOptions) error { // SearchActivities searches for activities by a query string func (c *Client) SearchActivities(query string) ([]Activity, error) { - // TODO: Implement internalClient.Client.SearchActivities - return nil, fmt.Errorf("not implemented") + params := url.Values{} + params.Add("search", query) + params.Add("limit", "20") // Default limit + + data, err := c.Client.ConnectAPI("/activitylist-service/activities/search/activities", "GET", params, nil) + if err != nil { + return nil, fmt.Errorf("failed to search activities: %w", err) + } + + if len(data) == 0 { + return []Activity{}, nil + } + + var garthActivities []garth.Activity + if err := json.Unmarshal(data, &garthActivities); err != nil { + return nil, fmt.Errorf("failed to parse search response: %w", err) + } + + var activities []Activity + for _, act := range garthActivities { + activities = append(activities, Activity{ + ActivityID: act.ActivityID, + ActivityName: act.ActivityName, + ActivityType: ActivityType(act.ActivityType), + StartTimeLocal: act.StartTimeLocal, + Distance: act.Distance, + Duration: act.Duration, + }) + } + + return activities, nil } // GetSleepData retrieves sleep data for a specified date range @@ -223,8 +277,27 @@ func (c *Client) GetTrainingLoad(date time.Time) (*TrainingLoad, error) { // GetFitnessAge retrieves fitness age calculation func (c *Client) GetFitnessAge() (*FitnessAge, error) { - // TODO: Implement GetFitnessAge in internalClient.Client - return nil, fmt.Errorf("GetFitnessAge not implemented in internalClient.Client") + data, err := c.Client.ConnectAPI("/fitness-service/fitness/fitnessAge", "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get fitness age: %w", err) + } + + if len(data) == 0 { + return nil, nil + } + + var fitnessAge garth.FitnessAge + if err := json.Unmarshal(data, &fitnessAge); err != nil { + return nil, fmt.Errorf("failed to parse fitness age response: %w", err) + } + + fitnessAge.LastUpdated = time.Now() + return &FitnessAge{ + FitnessAge: fitnessAge.FitnessAge, + ChronologicalAge: fitnessAge.ChronologicalAge, + VO2MaxRunning: fitnessAge.VO2MaxRunning, + LastUpdated: fitnessAge.LastUpdated, + }, nil } // OAuth1Token returns the OAuth1 token diff --git a/pkg/garth/client/client.go b/pkg/garth/client/client.go index 2a75da2..8f24f38 100644 --- a/pkg/garth/client/client.go +++ b/pkg/garth/client/client.go @@ -16,6 +16,7 @@ import ( "github.com/sstent/go-garth/internal/auth/sso" "github.com/sstent/go-garth/internal/errors" + "github.com/sstent/go-garth/internal/utils" garth "github.com/sstent/go-garth/pkg/garth/types" shared "github.com/sstent/go-garth/shared/interfaces" models "github.com/sstent/go-garth/shared/models" @@ -503,45 +504,233 @@ func (c *Client) GetActivities(limit int) ([]garth.Activity, error) { return activities, nil } -func (c *Client) GetSleepData(startDate, endDate time.Time) ([]garth.SleepData, error) { - // TODO: Implement GetSleepData - return nil, fmt.Errorf("GetSleepData not implemented") +// GetActivitiesWithOptions retrieves activities with filtering options +func (c *Client) GetActivitiesWithOptions(limit, offset int, activityType string, dateFrom, dateTo time.Time) ([]garth.Activity, error) { + if limit <= 0 { + limit = 10 + } + if offset < 0 { + offset = 0 + } + + scheme := "https" + if strings.HasPrefix(c.Domain, "127.0.0.1") { + scheme = "http" + } + + params := url.Values{} + params.Add("limit", fmt.Sprintf("%d", limit)) + params.Add("start", fmt.Sprintf("%d", offset)) + if activityType != "" { + params.Add("activityType", activityType) + } + if !dateFrom.IsZero() { + params.Add("startDate", dateFrom.Format("2006-01-02")) + } + if !dateTo.IsZero() { + params.Add("endDate", dateTo.Format("2006-01-02")) + } + + activitiesURL := fmt.Sprintf("%s://connectapi.%s/activitylist-service/activities/search/activities?%s", scheme, c.Domain, params.Encode()) + + req, err := http.NewRequest("GET", activitiesURL, nil) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to create activities request", + Cause: err, + }, + }, + } + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{ + Message: "Failed to get activities", + Cause: err, + }, + }, + } + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{ + GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(body), + GarthError: errors.GarthError{ + Message: "Activities request failed", + }, + }, + } + } + + var activities []garth.Activity + if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil { + return nil, &errors.IOError{ + GarthError: errors.GarthError{ + Message: "Failed to parse activities", + Cause: err, + }, + } + } + + return activities, nil } -// GetHrvData retrieves HRV data for a specified number of days -func (c *Client) GetHrvData(days int) ([]garth.HrvData, error) { - // TODO: Implement GetHrvData - return nil, fmt.Errorf("GetHrvData not implemented") +func (c *Client) GetSleepData(startDate, endDate time.Time) ([]garth.SleepData, error) { + path := fmt.Sprintf("/usersummary-service/stats/sleep/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get sleep data: %w", err) + } + + if len(data) == 0 { + return []garth.SleepData{}, nil + } + + var result []garth.SleepData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse sleep response: %w", err) + } + + return result, nil +} + +// GetHrvData retrieves HRV data for a specified date range +func (c *Client) GetHrvData(startDate, endDate time.Time) ([]garth.HrvData, error) { + path := fmt.Sprintf("/usersummary-service/stats/hrv/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get HRV data: %w", err) + } + + if len(data) == 0 { + return []garth.HrvData{}, nil + } + + var result []garth.HrvData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse HRV response: %w", err) + } + + return result, nil } // GetStressData retrieves stress data func (c *Client) GetStressData(startDate, endDate time.Time) ([]garth.StressData, error) { - // TODO: Implement GetStressData - return nil, fmt.Errorf("GetStressData not implemented") + path := fmt.Sprintf("/usersummary-service/stats/stress/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get stress data: %w", err) + } + + if len(data) == 0 { + return []garth.StressData{}, nil + } + + var result []garth.StressData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse stress response: %w", err) + } + + return result, nil } // GetBodyBatteryData retrieves Body Battery data func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]garth.BodyBatteryData, error) { - // TODO: Implement GetBodyBatteryData - return nil, fmt.Errorf("GetBodyBatteryData not implemented") + path := fmt.Sprintf("/usersummary-service/stats/bodybattery/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get Body Battery data: %w", err) + } + + if len(data) == 0 { + return []garth.BodyBatteryData{}, nil + } + + var result []garth.BodyBatteryData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse Body Battery response: %w", err) + } + + return result, nil } // GetStepsData retrieves steps data for a specified date range func (c *Client) GetStepsData(startDate, endDate time.Time) ([]garth.StepsData, error) { - // TODO: Implement GetStepsData - return nil, fmt.Errorf("GetStepsData not implemented") + path := fmt.Sprintf("/usersummary-service/stats/steps/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get steps data: %w", err) + } + + if len(data) == 0 { + return []garth.StepsData{}, nil + } + + var result []garth.StepsData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse steps response: %w", err) + } + + return result, nil } // GetDistanceData retrieves distance data for a specified date range func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]garth.DistanceData, error) { - // TODO: Implement GetDistanceData - return nil, fmt.Errorf("GetDistanceData not implemented") + path := fmt.Sprintf("/usersummary-service/stats/distance/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get distance data: %w", err) + } + + if len(data) == 0 { + return []garth.DistanceData{}, nil + } + + var result []garth.DistanceData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse distance response: %w", err) + } + + return result, nil } // GetCaloriesData retrieves calories data for a specified date range func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]garth.CaloriesData, error) { - // TODO: Implement GetCaloriesData - return nil, fmt.Errorf("GetCaloriesData not implemented") + path := fmt.Sprintf("/usersummary-service/stats/calories/daily/%s/%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02")) + + data, err := c.ConnectAPI(path, "GET", nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to get calories data: %w", err) + } + + if len(data) == 0 { + return []garth.CaloriesData{}, nil + } + + var result []garth.CaloriesData + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("failed to parse calories response: %w", err) + } + + return result, nil } // GetVO2MaxData retrieves VO2 max data using the modern approach via user settings @@ -959,6 +1148,56 @@ func (c *Client) LoadSession(filename string) error { // RefreshSession refreshes the authentication tokens func (c *Client) RefreshSession() error { - // TODO: Implement token refresh logic - return fmt.Errorf("RefreshSession not implemented") + if c.OAuth2Token == nil || c.OAuth2Token.RefreshToken == "" { + return fmt.Errorf("no refresh token available") + } + + consumer, err := utils.LoadOAuthConsumer() + if err != nil { + return fmt.Errorf("failed to load OAuth consumer: %w", err) + } + + scheme := "https" + if strings.HasPrefix(c.Domain, "127.0.0.1") { + scheme = "http" + } + tokenURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/token", scheme, c.Domain) + + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", c.OAuth2Token.RefreshToken) + + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create refresh request: %w", err) + } + + req.SetBasicAuth(consumer.ConsumerKey, consumer.ConsumerSecret) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "garth-go-client/1.0") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("refresh request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("refresh failed with status %d: %s", resp.StatusCode, body) + } + + var newToken garth.OAuth2Token + if err := json.NewDecoder(resp.Body).Decode(&newToken); err != nil { + return fmt.Errorf("failed to decode refresh response: %w", err) + } + + // Update token with new values while preserving existing fields + c.OAuth2Token.AccessToken = newToken.AccessToken + c.OAuth2Token.RefreshToken = newToken.RefreshToken + c.OAuth2Token.ExpiresIn = newToken.ExpiresIn + c.OAuth2Token.ExpiresAt = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second) + c.AuthToken = fmt.Sprintf("%s %s", newToken.TokenType, newToken.AccessToken) + + return nil }