package client import ( "bytes" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "net/http/cookiejar" "net/url" "os" "path/filepath" "strings" "time" "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" ) // Client represents the Garmin Connect API client type Client struct { Domain string HTTPClient *http.Client Username string AuthToken string OAuth1Token *garth.OAuth1Token OAuth2Token *garth.OAuth2Token } // Verify that Client implements shared.APIClient var _ shared.APIClient = (*Client)(nil) // GetUsername returns the authenticated username func (c *Client) GetUsername() string { return c.Username } // GetUserSettings retrieves the current user's settings func (c *Client) GetUserSettings() (*models.UserSettings, error) { scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } host := c.Domain if !strings.HasPrefix(c.Domain, "127.0.0.1") { host = "connectapi." + c.Domain } settingsURL := fmt.Sprintf("%s://%s/userprofile-service/userprofile/user-settings", scheme, host) req, err := http.NewRequest("GET", settingsURL, nil) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Failed to create user settings 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 user settings", 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: "User settings request failed", }, }, } } var settings models.UserSettings if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil { return nil, &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to parse user settings", Cause: err, }, } } return &settings, nil } // NewClient creates a new Garmin Connect client func NewClient(domain string) (*Client, error) { if domain == "" { domain = "garmin.com" } // Extract host without scheme if present if strings.Contains(domain, "://") { if u, err := url.Parse(domain); err == nil { domain = u.Host } } jar, err := cookiejar.New(nil) if err != nil { return nil, &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to create cookie jar", Cause: err, }, } } return &Client{ Domain: domain, HTTPClient: &http.Client{ Jar: jar, Timeout: 30 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 10 { return &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Too many redirects", }, }, } } return nil }, }, }, nil } // Login authenticates to Garmin Connect using SSO func (c *Client) Login(email, password string) error { // Extract host without scheme if present host := c.Domain if strings.Contains(host, "://") { if u, err := url.Parse(host); err == nil { host = u.Host } } ssoClient := sso.NewClient(c.Domain) oauth2Token, mfaContext, err := ssoClient.Login(email, password) if err != nil { return &errors.AuthenticationError{ GarthError: errors.GarthError{ Message: "SSO login failed", Cause: err, }, } } // Handle MFA required if mfaContext != nil { return &errors.AuthenticationError{ GarthError: errors.GarthError{ Message: "MFA required - not implemented yet", }, } } c.OAuth2Token = oauth2Token c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken) // Get user profile to set username profile, err := c.GetUserProfile() if err != nil { return &errors.AuthenticationError{ GarthError: errors.GarthError{ Message: "Failed to get user profile after login", Cause: err, }, } } c.Username = profile.UserName return nil } // Logout clears the current session and tokens. func (c *Client) Logout() error { c.AuthToken = "" c.Username = "" c.OAuth1Token = nil c.OAuth2Token = nil // Clear cookies if c.HTTPClient != nil && c.HTTPClient.Jar != nil { // Create a dummy URL for the domain to clear all cookies associated with it dummyURL, err := url.Parse(fmt.Sprintf("https://%s", c.Domain)) if err == nil { c.HTTPClient.Jar.SetCookies(dummyURL, []*http.Cookie{}) } } return nil } // GetUserProfile retrieves the current user's full profile func (c *Client) GetUserProfile() (*garth.UserProfile, error) { scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } host := c.Domain if !strings.HasPrefix(c.Domain, "127.0.0.1") { host = "connectapi." + c.Domain } profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, host) req, err := http.NewRequest("GET", profileURL, nil) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Failed to create profile 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 user profile", 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: "Profile request failed", }, }, } } var profile garth.UserProfile if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { return nil, &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to parse profile", Cause: err, }, } } return &profile, nil } // ConnectAPI makes a raw API request to the Garmin Connect API func (c *Client) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) { scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } u := &url.URL{ Scheme: scheme, Host: c.Domain, Path: path, RawQuery: params.Encode(), } req, err := http.NewRequest(method, u.String(), body) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Failed to create request", Cause: err, }, }, } } req.Header.Set("Authorization", c.AuthToken) req.Header.Set("User-Agent", "garth-go-client/1.0") req.Header.Set("Accept", "application/json") if body != nil && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } resp, err := c.HTTPClient.Do(req) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Request failed", Cause: err, }, }, } } defer resp.Body.Close() if resp.StatusCode >= 400 { bodyBytes, _ := io.ReadAll(resp.Body) return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ StatusCode: resp.StatusCode, Response: string(bodyBytes), GarthError: errors.GarthError{ Message: fmt.Sprintf("API request failed with status %d: %s", resp.StatusCode, tryReadErrorBody(bytes.NewReader(bodyBytes))), }, }, } } return io.ReadAll(resp.Body) } func tryReadErrorBody(r io.Reader) string { body, err := io.ReadAll(r) if err != nil { return "failed to read error response" } return string(body) } // Upload sends a file to Garmin Connect func (c *Client) Upload(filePath string) error { file, err := os.Open(filePath) if err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to open file", Cause: err, }, } } defer file.Close() body := &bytes.Buffer{} writer := multipart.NewWriter(body) part, err := writer.CreateFormFile("file", filepath.Base(filePath)) if err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to create form file", Cause: err, }, } } if _, err := io.Copy(part, file); err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to copy file content", Cause: err, }, } } if err := writer.Close(); err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to close multipart writer", Cause: err, }, } } _, err = c.ConnectAPI("/upload-service/upload", "POST", nil, body) if err != nil { return &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "File upload failed", Cause: err, }, }, } } return nil } // Download retrieves a file from Garmin Connect func (c *Client) Download(activityID string, format string, filePath string) error { params := url.Values{} params.Add("activityId", activityID) // Add format parameter if provided and not empty if format != "" { params.Add("format", format) } resp, err := c.ConnectAPI("/download-service/export", "GET", params, nil) if err != nil { return err } if err := os.WriteFile(filePath, resp, 0644); err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to save file", Cause: err, }, } } return nil } // GetActivities retrieves recent activities func (c *Client) GetActivities(limit int) ([]garth.Activity, error) { if limit <= 0 { limit = 10 } scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } activitiesURL := fmt.Sprintf("%s://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", scheme, c.Domain, limit) 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 } // 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 } 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) { 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) { 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) { 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) { 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) { 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 func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]garth.VO2MaxData, error) { // Get user settings which contains current VO2 max values settings, err := c.GetUserSettings() if err != nil { return nil, fmt.Errorf("failed to get user settings: %w", err) } // Create VO2MaxData for the date range var results []garth.VO2MaxData current := startDate for !current.After(endDate) { vo2Data := garth.VO2MaxData{ Date: current, UserProfilePK: settings.ID, } // Set VO2 max values if available if settings.UserData.VO2MaxRunning != nil { vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning } if settings.UserData.VO2MaxCycling != nil { vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling } results = append(results, vo2Data) current = current.AddDate(0, 0, 1) } return results, nil } // GetCurrentVO2Max retrieves the current VO2 max values from user profile func (c *Client) GetCurrentVO2Max() (*garth.VO2MaxProfile, error) { settings, err := c.GetUserSettings() if err != nil { return nil, fmt.Errorf("failed to get user settings: %w", err) } profile := &garth.VO2MaxProfile{ UserProfilePK: settings.ID, LastUpdated: time.Now(), } // Add running VO2 max if available if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 { profile.Running = &garth.VO2MaxEntry{ Value: *settings.UserData.VO2MaxRunning, ActivityType: "running", Date: time.Now(), Source: "user_settings", } } // Add cycling VO2 max if available if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 { profile.Cycling = &garth.VO2MaxEntry{ Value: *settings.UserData.VO2MaxCycling, ActivityType: "cycling", Date: time.Now(), Source: "user_settings", } } return profile, nil } // GetHeartRateZones retrieves heart rate zone data func (c *Client) GetHeartRateZones() (*garth.HeartRateZones, error) { scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } hrzURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/userprofile/heartRateZones", scheme, c.Domain) req, err := http.NewRequest("GET", hrzURL, nil) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Failed to create HR zones 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 HR zones data", 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: "HR zones request failed", }, }, } } var hrZones garth.HeartRateZones if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil { return nil, &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to parse HR zones data", Cause: err, }, } } return &hrZones, nil } // GetWellnessData retrieves comprehensive wellness data for a specified date range func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]garth.WellnessData, error) { scheme := "https" if strings.HasPrefix(c.Domain, "127.0.0.1") { scheme = "http" } params := url.Values{} params.Add("startDate", startDate.Format("2006-01-02")) params.Add("endDate", endDate.Format("2006-01-02")) wellnessURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/wellness?%s", scheme, c.Domain, params.Encode()) req, err := http.NewRequest("GET", wellnessURL, nil) if err != nil { return nil, &errors.APIError{ GarthHTTPError: errors.GarthHTTPError{ GarthError: errors.GarthError{ Message: "Failed to create wellness data 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 wellness data", 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: "Wellness data request failed", }, }, } } var wellnessData []garth.WellnessData if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil { return nil, &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to parse wellness data", Cause: err, }, } } return wellnessData, nil } // SaveSession saves the current session to a file func (c *Client) SaveSession(filename string) error { session := garth.SessionData{ Domain: c.Domain, Username: c.Username, AuthToken: c.AuthToken, } data, err := json.MarshalIndent(session, "", " ") if err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to marshal session", Cause: err, }, } } if err := os.WriteFile(filename, data, 0600); err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to write session file", Cause: err, }, } } return nil } // GetDetailedSleepData retrieves comprehensive sleep data for a date func (c *Client) GetDetailedSleepData(date time.Time) (*garth.DetailedSleepData, error) { dateStr := date.Format("2006-01-02") path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60", c.Username, dateStr) data, err := c.ConnectAPI(path, "GET", nil, nil) if err != nil { return nil, fmt.Errorf("failed to get detailed sleep data: %w", err) } if len(data) == 0 { return nil, nil } var response struct { DailySleepDTO *garth.DetailedSleepData `json:"dailySleepDTO"` SleepMovement []garth.SleepMovement `json:"sleepMovement"` RemSleepData bool `json:"remSleepData"` SleepLevels []garth.SleepLevel `json:"sleepLevels"` SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"` RestlessMomentsCount int `json:"restlessMomentsCount"` WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"` WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"` WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"` SleepStress interface{} `json:"sleepStress"` } if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err) } if response.DailySleepDTO == nil { return nil, nil } // Populate additional data response.DailySleepDTO.SleepMovement = response.SleepMovement response.DailySleepDTO.SleepLevels = response.SleepLevels return response.DailySleepDTO, nil } // GetDailyHRVData retrieves comprehensive daily HRV data for a date func (c *Client) GetDailyHRVData(date time.Time) (*garth.DailyHRVData, error) { dateStr := date.Format("2006-01-02") path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s", c.Username, dateStr) 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 nil, nil } var response struct { HRVSummary garth.DailyHRVData `json:"hrvSummary"` HRVReadings []garth.HRVReading `json:"hrvReadings"` } if err := json.Unmarshal(data, &response); err != nil { return nil, fmt.Errorf("failed to parse HRV response: %w", err) } // Combine summary and readings response.HRVSummary.HRVReadings = response.HRVReadings return &response.HRVSummary, nil } // GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*garth.DetailedBodyBatteryData, error) { dateStr := date.Format("2006-01-02") // Get main Body Battery data path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr) data1, err := c.ConnectAPI(path1, "GET", nil, nil) if err != nil { return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err) } // Get Body Battery events path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr) data2, err := c.ConnectAPI(path2, "GET", nil, nil) if err != nil { // Events might not be available, continue without them data2 = []byte("[]") } var result garth.DetailedBodyBatteryData if len(data1) > 0 { if err := json.Unmarshal(data1, &result); err != nil { return nil, fmt.Errorf("failed to parse Body Battery data: %w", err) } } var events []garth.BodyBatteryEvent if len(data2) > 0 { if err := json.Unmarshal(data2, &events); err == nil { result.Events = events } } return &result, nil } // GetTrainingStatus retrieves current training status func (c *Client) GetTrainingStatus(date time.Time) (*garth.TrainingStatus, error) { dateStr := date.Format("2006-01-02") path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr) data, err := c.ConnectAPI(path, "GET", nil, nil) if err != nil { return nil, fmt.Errorf("failed to get training status: %w", err) } if len(data) == 0 { return nil, nil } var result garth.TrainingStatus if err := json.Unmarshal(data, &result); err != nil { return nil, fmt.Errorf("failed to parse training status: %w", err) } return &result, nil } // GetTrainingLoad retrieves training load data func (c *Client) GetTrainingLoad(date time.Time) (*garth.TrainingLoad, error) { dateStr := date.Format("2006-01-02") endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate) data, err := c.ConnectAPI(path, "GET", nil, nil) if err != nil { return nil, fmt.Errorf("failed to get training load: %w", err) } if len(data) == 0 { return nil, nil } var results []garth.TrainingLoad if err := json.Unmarshal(data, &results); err != nil { return nil, fmt.Errorf("failed to parse training load: %w", err) } if len(results) == 0 { return nil, nil } return &results[0], nil } // LoadSession loads a session from a file func (c *Client) LoadSession(filename string) error { data, err := os.ReadFile(filename) if err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to read session file", Cause: err, }, } } var session garth.SessionData if err := json.Unmarshal(data, &session); err != nil { return &errors.IOError{ GarthError: errors.GarthError{ Message: "Failed to unmarshal session", Cause: err, }, } } c.Domain = session.Domain c.Username = session.Username c.AuthToken = session.AuthToken return nil } // RefreshSession refreshes the authentication tokens func (c *Client) RefreshSession() error { 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 }