reworked api interfaces

This commit is contained in:
2025-09-22 16:41:49 -07:00
parent f2256a9cfe
commit 1b3fb04dcd
44 changed files with 1356 additions and 207 deletions

View File

@@ -1,14 +1,13 @@
package oauth
import (
"github.com/sstent/go-garth/internal/auth/oauth"
"github.com/sstent/go-garth/internal/models/types"
"github.com/sstent/go-garth/pkg/garmin"
garthoauth "github.com/sstent/go-garth/pkg/garth/auth/oauth"
)
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
func GetOAuth1Token(domain, ticket string) (*garmin.OAuth1Token, error) {
token, err := oauth.GetOAuth1Token(domain, ticket)
token, err := garthoauth.GetOAuth1Token(domain, ticket)
if err != nil {
return nil, err
}
@@ -17,7 +16,7 @@ func GetOAuth1Token(domain, ticket string) (*garmin.OAuth1Token, error) {
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
func ExchangeToken(oauth1Token *garmin.OAuth1Token) (*garmin.OAuth2Token, error) {
token, err := oauth.ExchangeToken((*types.OAuth1Token)(oauth1Token))
token, err := garthoauth.ExchangeToken(oauth1Token)
if err != nil {
return nil, err
}

View File

@@ -2,7 +2,7 @@ package garmin_test
import (
"encoding/json"
"github.com/sstent/go-garth/internal/api/client"
"github.com/sstent/go-garth/pkg/garth/client"
"github.com/sstent/go-garth/internal/data"
"github.com/sstent/go-garth/internal/testutils"
"testing"

View File

@@ -8,9 +8,8 @@ import (
"path/filepath"
"time"
internalClient "github.com/sstent/go-garth/internal/api/client"
internalClient "github.com/sstent/go-garth/pkg/garth/client"
"github.com/sstent/go-garth/internal/errors"
types "github.com/sstent/go-garth/internal/models/types"
shared "github.com/sstent/go-garth/shared/interfaces"
models "github.com/sstent/go-garth/shared/models"
)
@@ -51,12 +50,12 @@ func (c *Client) GetUserSettings() (*models.UserSettings, error) {
}
// GetUserProfile implements the APIClient interface
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
func (c *Client) GetUserProfile() (*UserProfile, error) {
return c.Client.GetUserProfile()
}
// GetWellnessData implements the APIClient interface
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) {
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]WellnessData, error) {
return c.Client.GetWellnessData(startDate, endDate)
}
@@ -168,72 +167,72 @@ func (c *Client) SearchActivities(query string) ([]Activity, error) {
}
// GetSleepData retrieves sleep data for a specified date range
func (c *Client) GetSleepData(date time.Time) (*types.DetailedSleepData, error) {
func (c *Client) GetSleepData(date time.Time) (*DetailedSleepData, error) {
return c.Client.GetDetailedSleepData(date)
}
// GetHrvData retrieves HRV data for a specified number of days
func (c *Client) GetHrvData(date time.Time) (*types.DailyHRVData, error) {
func (c *Client) GetHrvData(date time.Time) (*DailyHRVData, error) {
return c.Client.GetDailyHRVData(date)
}
// GetStressData retrieves stress data
func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) {
func (c *Client) GetStressData(startDate, endDate time.Time) ([]StressData, error) {
return c.Client.GetStressData(startDate, endDate)
}
// GetBodyBatteryData retrieves Body Battery data
func (c *Client) GetBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
func (c *Client) GetBodyBatteryData(date time.Time) (*DetailedBodyBatteryData, error) {
return c.Client.GetDetailedBodyBatteryData(date)
}
// GetStepsData retrieves steps data for a specified date range
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) {
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]StepsData, error) {
return c.Client.GetStepsData(startDate, endDate)
}
// GetDistanceData retrieves distance data for a specified date range
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) {
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]DistanceData, error) {
return c.Client.GetDistanceData(startDate, endDate)
}
// GetCaloriesData retrieves calories data for a specified date range
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) {
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]CaloriesData, error) {
return c.Client.GetCaloriesData(startDate, endDate)
}
// GetVO2MaxData retrieves VO2 max data for a specified date range
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]VO2MaxData, error) {
return c.Client.GetVO2MaxData(startDate, endDate)
}
// GetHeartRateZones retrieves heart rate zone data
func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
func (c *Client) GetHeartRateZones() (*HeartRateZones, error) {
return c.Client.GetHeartRateZones()
}
// GetTrainingStatus retrieves current training status
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
func (c *Client) GetTrainingStatus(date time.Time) (*TrainingStatus, error) {
return c.Client.GetTrainingStatus(date)
}
// GetTrainingLoad retrieves training load data
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
func (c *Client) GetTrainingLoad(date time.Time) (*TrainingLoad, error) {
return c.Client.GetTrainingLoad(date)
}
// GetFitnessAge retrieves fitness age calculation
func (c *Client) GetFitnessAge() (*types.FitnessAge, error) {
func (c *Client) GetFitnessAge() (*FitnessAge, error) {
// TODO: Implement GetFitnessAge in internalClient.Client
return nil, fmt.Errorf("GetFitnessAge not implemented in internalClient.Client")
}
// OAuth1Token returns the OAuth1 token
func (c *Client) OAuth1Token() *types.OAuth1Token {
func (c *Client) OAuth1Token() *OAuth1Token {
return c.Client.OAuth1Token
}
// OAuth2Token returns the OAuth2 token
func (c *Client) OAuth2Token() *types.OAuth2Token {
func (c *Client) OAuth2Token() *OAuth2Token {
return c.Client.OAuth2Token
}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"time"
internalClient "github.com/sstent/go-garth/internal/api/client"
internalClient "github.com/sstent/go-garth/pkg/garth/client"
"github.com/sstent/go-garth/internal/models/types"
)

View File

@@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/sstent/go-garth/internal/api/client"
"github.com/sstent/go-garth/pkg/garth/client"
"github.com/sstent/go-garth/internal/data"
"github.com/sstent/go-garth/internal/stats"
)

View File

@@ -1,8 +1,6 @@
package garmin
import (
"time"
"github.com/sstent/go-garth/internal/stats"
)
@@ -38,21 +36,3 @@ func NewDailySleep() Stats {
func NewDailyHRV() Stats {
return stats.NewDailyHRV()
}
// StepsData represents steps statistics
type StepsData struct {
Date time.Time `json:"calendarDate"`
Steps int `json:"steps"`
}
// DistanceData represents distance statistics
type DistanceData struct {
Date time.Time `json:"calendarDate"`
Distance float64 `json:"distance"` // in meters
}
// CaloriesData represents calories statistics
type CaloriesData struct {
Date time.Time `json:"calendarDate"`
Calories int `json:"activeCalories"`
}

View File

@@ -1,90 +1,99 @@
package garmin
import types "github.com/sstent/go-garth/internal/models/types"
import garth "github.com/sstent/go-garth/pkg/garth/types"
// GarminTime represents Garmin's timestamp format with custom JSON parsing
type GarminTime = types.GarminTime
type GarminTime = garth.GarminTime
// SessionData represents saved session information
type SessionData = types.SessionData
type SessionData = garth.SessionData
// ActivityType represents the type of activity
type ActivityType = types.ActivityType
type ActivityType = garth.ActivityType
// EventType represents the event type of an activity
type EventType = types.EventType
type EventType = garth.EventType
// Activity represents a Garmin Connect activity
type Activity = types.Activity
type Activity = garth.Activity
// UserProfile represents a Garmin user profile
type UserProfile = types.UserProfile
type UserProfile = garth.UserProfile
// OAuth1Token represents OAuth1 token response
type OAuth1Token = types.OAuth1Token
type OAuth1Token = garth.OAuth1Token
// OAuth2Token represents OAuth2 token response
type OAuth2Token = types.OAuth2Token
type OAuth2Token = garth.OAuth2Token
// DetailedSleepData represents comprehensive sleep data
type DetailedSleepData = types.DetailedSleepData
type DetailedSleepData = garth.DetailedSleepData
// SleepLevel represents different sleep stages
type SleepLevel = types.SleepLevel
type SleepLevel = garth.SleepLevel
// SleepMovement represents movement during sleep
type SleepMovement = types.SleepMovement
type SleepMovement = garth.SleepMovement
// SleepScore represents detailed sleep scoring
type SleepScore = types.SleepScore
type SleepScore = garth.SleepScore
// SleepScoreBreakdown represents breakdown of sleep score
type SleepScoreBreakdown = types.SleepScoreBreakdown
type SleepScoreBreakdown = garth.SleepScoreBreakdown
// HRVBaseline represents HRV baseline data
type HRVBaseline = types.HRVBaseline
type HRVBaseline = garth.HRVBaseline
// DailyHRVData represents comprehensive daily HRV data
type DailyHRVData = types.DailyHRVData
type DailyHRVData = garth.DailyHRVData
// BodyBatteryEvent represents events that impact Body Battery
type BodyBatteryEvent = types.BodyBatteryEvent
type BodyBatteryEvent = garth.BodyBatteryEvent
// DetailedBodyBatteryData represents comprehensive Body Battery data
type DetailedBodyBatteryData = types.DetailedBodyBatteryData
type DetailedBodyBatteryData = garth.DetailedBodyBatteryData
// TrainingStatus represents current training status
type TrainingStatus = types.TrainingStatus
type TrainingStatus = garth.TrainingStatus
// TrainingLoad represents training load data
type TrainingLoad = types.TrainingLoad
type TrainingLoad = garth.TrainingLoad
// FitnessAge represents fitness age calculation
type FitnessAge = types.FitnessAge
type FitnessAge = garth.FitnessAge
// VO2MaxData represents VO2 max data
type VO2MaxData = types.VO2MaxData
type VO2MaxData = garth.VO2MaxData
// VO2MaxEntry represents a single VO2 max entry
type VO2MaxEntry = types.VO2MaxEntry
type VO2MaxEntry = garth.VO2MaxEntry
// HeartRateZones represents heart rate zone data
type HeartRateZones = types.HeartRateZones
type HeartRateZones = garth.HeartRateZones
// HRZone represents a single heart rate zone
type HRZone = types.HRZone
type HRZone = garth.HRZone
// WellnessData represents additional wellness metrics
type WellnessData = types.WellnessData
type WellnessData = garth.WellnessData
// SleepData represents sleep summary data
type SleepData = types.SleepData
type SleepData = garth.SleepData
// HrvData represents Heart Rate Variability data
type HrvData = types.HrvData
type HrvData = garth.HrvData
// StressData represents stress level data
type StressData = types.StressData
type StressData = garth.StressData
// BodyBatteryData represents Body Battery data
type BodyBatteryData = types.BodyBatteryData
type BodyBatteryData = garth.BodyBatteryData
// StepsData represents steps statistics
type StepsData = garth.StepsData
// DistanceData represents distance statistics
type DistanceData = garth.DistanceData
// CaloriesData represents calories statistics
type CaloriesData = garth.CaloriesData

View File

@@ -0,0 +1,37 @@
package auth
import (
"fmt"
"os"
"path/filepath"
"github.com/joho/godotenv"
)
// LoadEnvCredentials loads credentials from .env file
func LoadEnvCredentials() (email, password, domain string, err error) {
// Determine project root (assuming .env is in the project root)
projectRoot := "/home/sstent/Projects/go-garth"
envPath := filepath.Join(projectRoot, ".env")
// Load .env file
if err := godotenv.Load(envPath); err != nil {
return "", "", "", fmt.Errorf("error loading .env file from %s: %w", envPath, err)
}
email = os.Getenv("GARMIN_EMAIL")
password = os.Getenv("GARMIN_PASSWORD")
domain = os.Getenv("GARMIN_DOMAIN")
if email == "" {
return "", "", "", fmt.Errorf("GARMIN_EMAIL not found in .env file")
}
if password == "" {
return "", "", "", fmt.Errorf("GARMIN_PASSWORD not found in .env file")
}
if domain == "" {
domain = "garmin.com" // default value
}
return email, password, domain, nil
}

View File

@@ -0,0 +1,4 @@
// Package credentials provides helpers for loading user credentials and
// environment configuration used during authentication and local development.
// Note: This is an internal package and not intended for direct external use.
package auth

View File

@@ -0,0 +1,5 @@
// Package oauth contains low-level OAuth1 and OAuth2 flows used by SSO to
// obtain and exchange tokens. It handles request signing, headers, and response
// parsing to produce strongly-typed token structures for the client.
// Note: This is an internal package and not intended for direct external use.
package auth

View File

@@ -0,0 +1,162 @@
package auth
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sstent/go-garth/internal/utils"
garth "github.com/sstent/go-garth/pkg/garth/types"
)
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
func GetOAuth1Token(domain, ticket string) (*garth.OAuth1Token, error) {
scheme := "https"
if strings.HasPrefix(domain, "127.0.0.1") {
scheme = "http"
}
consumer, err := utils.LoadOAuthConsumer()
if err != nil {
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
}
baseURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/", scheme, domain)
loginURL := fmt.Sprintf("%s://sso.%s/sso/embed", scheme, domain)
tokenURL := fmt.Sprintf("%spreauthorized?ticket=%s&login-url=%s&accepts-mfa-tokens=true",
baseURL, ticket, url.QueryEscape(loginURL))
// Parse URL to extract query parameters for signing
parsedURL, err := url.Parse(tokenURL)
if err != nil {
return nil, err
}
// Extract query parameters
queryParams := make(map[string]string)
for key, values := range parsedURL.Query() {
if len(values) > 0 {
queryParams[key] = values[0]
}
}
// Create OAuth1 signed request
baseURLForSigning := parsedURL.Scheme + "://" + parsedURL.Host + parsedURL.Path
authHeader := utils.CreateOAuth1AuthorizationHeader("GET", baseURLForSigning, queryParams,
consumer.ConsumerKey, consumer.ConsumerSecret, "", "")
req, err := http.NewRequest("GET", tokenURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
bodyStr := string(body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth1 request failed with status %d: %s", resp.StatusCode, bodyStr)
}
// Parse query string response - handle both & and ; separators
bodyStr = strings.ReplaceAll(bodyStr, ";", "&")
values, err := url.ParseQuery(bodyStr)
if err != nil {
return nil, fmt.Errorf("failed to parse OAuth1 response: %w", err)
}
oauthToken := values.Get("oauth_token")
oauthTokenSecret := values.Get("oauth_token_secret")
if oauthToken == "" || oauthTokenSecret == "" {
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
}
return &garth.OAuth1Token{
OAuthToken: oauthToken,
OAuthTokenSecret: oauthTokenSecret,
MFAToken: values.Get("mfa_token"),
Domain: domain,
}, nil
}
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
func ExchangeToken(oauth1Token *garth.OAuth1Token) (*garth.OAuth2Token, error) {
scheme := "https"
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
scheme = "http"
}
consumer, err := utils.LoadOAuthConsumer()
if err != nil {
return nil, fmt.Errorf("failed to load OAuth consumer: %w", err)
}
exchangeURL := fmt.Sprintf("%s://connectapi.%s/oauth-service/oauth/exchange/user/2.0", scheme, oauth1Token.Domain)
// Prepare form data
formData := url.Values{}
if oauth1Token.MFAToken != "" {
formData.Set("mfa_token", oauth1Token.MFAToken)
}
// Convert form data to map for OAuth signing
formParams := make(map[string]string)
for key, values := range formData {
if len(values) > 0 {
formParams[key] = values[0]
}
}
// Create OAuth1 signed request
authHeader := utils.CreateOAuth1AuthorizationHeader("POST", exchangeURL, formParams,
consumer.ConsumerKey, consumer.ConsumerSecret, oauth1Token.OAuthToken, oauth1Token.OAuthTokenSecret)
req, err := http.NewRequest("POST", exchangeURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", authHeader)
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
}
var oauth2Token garth.OAuth2Token
if err := json.Unmarshal(body, &oauth2Token); err != nil {
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
}
// Set expiration time
if oauth2Token.ExpiresIn > 0 {
oauth2Token.ExpiresAt = time.Now().Add(time.Duration(oauth2Token.ExpiresIn) * time.Second)
}
return &oauth2Token, nil
}

View File

@@ -0,0 +1,5 @@
// Package sso implements the Garmin SSO login flow. It orchestrates CSRF,
// ticket exchange, MFA placeholders, and token retrieval, delegating OAuth
// details to internal/auth/oauth. The internal client consumes this package.
// Note: This is an internal package and not intended for direct external use.
package auth

265
pkg/garth/auth/sso/sso.go Normal file
View File

@@ -0,0 +1,265 @@
package auth
import (
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
oauth "github.com/sstent/go-garth/pkg/garth/auth/oauth"
types "github.com/sstent/go-garth/pkg/garth/types"
)
var (
csrfRegex = regexp.MustCompile(`name="_csrf"\s+value="(.+?)"`)
titleRegex = regexp.MustCompile(`<title>(.+?)</title>`)
ticketRegex = regexp.MustCompile(`embed\?ticket=([^"]+)"`)
)
// MFAContext preserves state for resuming MFA login
type MFAContext struct {
SigninURL string
CSRFToken string
Ticket string
}
// Client represents an SSO client
type Client struct {
Domain string
HTTPClient *http.Client
}
// NewClient creates a new SSO client
func NewClient(domain string) *Client {
return &Client{
Domain: domain,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
}
}
// Login performs the SSO authentication flow
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
scheme := "https"
if strings.HasPrefix(c.Domain, "127.0.0.1") {
scheme = "http"
}
// Step 1: Set up SSO parameters
ssoURL := fmt.Sprintf("https://sso.%s/sso", c.Domain)
ssoEmbedURL := fmt.Sprintf("%s/embed", ssoURL)
ssoEmbedParams := url.Values{
"id": {"gauth-widget"},
"embedWidget": {"true"},
"gauthHost": {ssoURL},
}
signinParams := url.Values{
"id": {"gauth-widget"},
"embedWidget": {"true"},
"gauthHost": {ssoEmbedURL},
"service": {ssoEmbedURL},
"source": {ssoEmbedURL},
"redirectAfterAccountLoginUrl": {ssoEmbedURL},
"redirectAfterAccountCreationUrl": {ssoEmbedURL},
}
// Step 2: Initialize SSO session
fmt.Println("Initializing SSO session...")
embedURL := fmt.Sprintf("https://sso.%s/sso/embed?%s", c.Domain, ssoEmbedParams.Encode())
req, err := http.NewRequest("GET", embedURL, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to create embed request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize SSO: %w", err)
}
resp.Body.Close()
// Step 3: Get signin page and CSRF token
fmt.Println("Getting signin page...")
signinURL := fmt.Sprintf("%s://sso.%s/sso/signin?%s", scheme, c.Domain, signinParams.Encode())
req, err = http.NewRequest("GET", signinURL, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to create signin request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
req.Header.Set("Referer", embedURL)
resp, err = c.HTTPClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to get signin page: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read signin response: %w", err)
}
// Extract CSRF token
csrfToken := extractCSRFToken(string(body))
if csrfToken == "" {
return nil, nil, fmt.Errorf("failed to find CSRF token")
}
fmt.Printf("Found CSRF token: %s\n", csrfToken[:10]+"...")
// Step 4: Submit login form
fmt.Println("Submitting login credentials...")
formData := url.Values{
"username": {email},
"password": {password},
"embed": {"true"},
"_csrf": {csrfToken},
}
req, err = http.NewRequest("POST", signinURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, nil, fmt.Errorf("failed to create login request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
req.Header.Set("Referer", signinURL)
resp, err = c.HTTPClient.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to submit login: %w", err)
}
defer resp.Body.Close()
body, err = io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read login response: %w", err)
}
// Check login result
title := extractTitle(string(body))
fmt.Printf("Login response title: %s\n", title)
// Handle MFA requirement
if strings.Contains(title, "MFA") {
fmt.Println("MFA required - returning context for ResumeLogin")
ticket := extractTicket(string(body))
return nil, &MFAContext{
SigninURL: signinURL,
CSRFToken: csrfToken,
Ticket: ticket,
}, nil
}
if title != "Success" {
return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title)
}
// Step 5: Extract ticket for OAuth flow
fmt.Println("Extracting OAuth ticket...")
ticket := extractTicket(string(body))
if ticket == "" {
return nil, nil, fmt.Errorf("failed to find OAuth ticket")
}
fmt.Printf("Found ticket: %s\n", ticket[:10]+"...")
// Step 6: Get OAuth1 token
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
if err != nil {
return nil, nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
}
fmt.Println("Got OAuth1 token")
// Step 7: Exchange for OAuth2 token
oauth2Token, err := oauth.ExchangeToken(oauth1Token)
if err != nil {
return nil, nil, fmt.Errorf("failed to exchange for OAuth2 token: %w", err)
}
fmt.Printf("Got OAuth2 token: %s\n", oauth2Token.TokenType)
return oauth2Token, nil, nil
}
// ResumeLogin completes authentication after MFA challenge
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
fmt.Println("Resuming login with MFA code...")
// Submit MFA form
formData := url.Values{
"mfa-code": {mfaCode},
"embed": {"true"},
"_csrf": {ctx.CSRFToken},
}
req, err := http.NewRequest("POST", ctx.SigninURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create MFA request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
req.Header.Set("Referer", ctx.SigninURL)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to submit MFA: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read MFA response: %w", err)
}
// Verify MFA success
title := extractTitle(string(body))
if title != "Success" {
return nil, fmt.Errorf("MFA failed, unexpected title: %s", title)
}
// Continue with ticket flow
fmt.Println("Extracting OAuth ticket after MFA...")
ticket := extractTicket(string(body))
if ticket == "" {
return nil, fmt.Errorf("failed to find OAuth ticket after MFA")
}
// Get OAuth1 token
oauth1Token, err := oauth.GetOAuth1Token(c.Domain, ticket)
if err != nil {
return nil, fmt.Errorf("failed to get OAuth1 token: %w", err)
}
// Exchange for OAuth2 token
return oauth.ExchangeToken(oauth1Token)
}
// extractCSRFToken extracts CSRF token from HTML
func extractCSRFToken(html string) string {
matches := csrfRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// extractTitle extracts page title from HTML
func extractTitle(html string) string {
matches := titleRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}
// extractTicket extracts OAuth ticket from HTML
func extractTicket(html string) string {
matches := ticketRegex.FindStringSubmatch(html)
if len(matches) > 1 {
return matches[1]
}
return ""
}

37
pkg/garth/client/auth.go Normal file
View File

@@ -0,0 +1,37 @@
package client
import (
"time"
)
// OAuth1Token represents OAuth 1.0a credentials
type OAuth1Token struct {
Token string
TokenSecret string
CreatedAt time.Time
}
// Expired checks if token is expired (OAuth1 tokens typically don't expire but we'll implement for consistency)
func (t *OAuth1Token) Expired() bool {
return false // OAuth1 tokens don't typically expire
}
// OAuth2Token represents OAuth 2.0 credentials
type OAuth2Token struct {
AccessToken string
RefreshToken string
TokenType string
ExpiresIn int
ExpiresAt time.Time
}
// Expired checks if token is expired
func (t *OAuth2Token) Expired() bool {
return time.Now().After(t.ExpiresAt)
}
// RefreshIfNeeded refreshes token if expired (implementation pending)
func (t *OAuth2Token) RefreshIfNeeded(client *Client) error {
// Placeholder for token refresh logic
return nil
}

View File

@@ -0,0 +1,36 @@
package client
import (
"testing"
credentials "github.com/sstent/go-garth/pkg/garth/auth/credentials"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_Login_Functional(t *testing.T) {
if testing.Short() {
t.Skip("Skipping functional test in short mode")
}
// Load credentials from .env file
email, password, domain, err := credentials.LoadEnvCredentials()
require.NoError(t, err, "Failed to load credentials from .env file. Please ensure GARMIN_EMAIL, GARMIN_PASSWORD, and GARMIN_DOMAIN are set.")
// Create client
c, err := NewClient(domain)
require.NoError(t, err, "Failed to create client")
// Perform login
err = c.Login(email, password)
require.NoError(t, err, "Login failed")
// Verify login
assert.NotEmpty(t, c.AuthToken, "AuthToken should not be empty after login")
assert.NotEmpty(t, c.Username, "Username should not be empty after login")
// Logout for cleanup
err = c.Logout()
assert.NoError(t, err, "Logout failed")
}

964
pkg/garth/client/client.go Normal file
View File

@@ -0,0 +1,964 @@
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"
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
}
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]garth.SleepData, error) {
// TODO: Implement GetSleepData
return nil, fmt.Errorf("GetSleepData not implemented")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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")
}
// 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 {
// TODO: Implement token refresh logic
return fmt.Errorf("RefreshSession not implemented")
}

View File

@@ -0,0 +1,48 @@
package client_test
import (
"crypto/tls"
"net/http"
"net/url"
"testing"
"time"
"github.com/sstent/go-garth/internal/testutils"
"github.com/sstent/go-garth/pkg/garth/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClient_GetUserProfile(t *testing.T) {
// Create mock server returning user profile
server := testutils.MockJSONResponse(http.StatusOK, `{
"userName": "testuser",
"displayName": "Test User",
"fullName": "Test User",
"location": "Test Location"
}`)
defer server.Close()
// Create client with test configuration
u, _ := url.Parse(server.URL)
c, err := client.NewClient(u.Host)
require.NoError(t, err)
c.Domain = u.Host
require.NoError(t, err)
c.HTTPClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
c.AuthToken = "Bearer testtoken"
// Get user profile
profile, err := c.GetUserProfile()
// Verify response
require.NoError(t, err)
assert.Equal(t, "testuser", profile.UserName)
assert.Equal(t, "Test User", profile.DisplayName)
}

6
pkg/garth/client/doc.go Normal file
View File

@@ -0,0 +1,6 @@
// Package client implements the low-level Garmin Connect HTTP client.
// It is responsible for authentication (via SSO helpers), request construction,
// header and cookie handling, error mapping, and JSON decoding. Higher-level
// public APIs in pkg/garmin delegate to this package for actual network I/O.
// Note: This is an internal package and not intended for direct external use.
package client

4
pkg/garth/client/http.go Normal file
View File

@@ -0,0 +1,4 @@
package client
// This file intentionally left blank.
// All HTTP client methods are now implemented in client.go.

View File

@@ -0,0 +1,11 @@
package client
import (
"io"
"net/url"
)
// HTTPClient defines the interface for HTTP operations
type HTTPClient interface {
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
}

View File

@@ -0,0 +1,71 @@
package client
import (
"time"
)
type UserProfile struct {
ID int `json:"id"`
ProfileID int `json:"profileId"`
GarminGUID string `json:"garminGuid"`
DisplayName string `json:"displayName"`
FullName string `json:"fullName"`
UserName string `json:"userName"`
ProfileImageType *string `json:"profileImageType"`
ProfileImageURLLarge *string `json:"profileImageUrlLarge"`
ProfileImageURLMedium *string `json:"profileImageUrlMedium"`
ProfileImageURLSmall *string `json:"profileImageUrlSmall"`
Location *string `json:"location"`
FacebookURL *string `json:"facebookUrl"`
TwitterURL *string `json:"twitterUrl"`
PersonalWebsite *string `json:"personalWebsite"`
Motivation *string `json:"motivation"`
Bio *string `json:"bio"`
PrimaryActivity *string `json:"primaryActivity"`
FavoriteActivityTypes []string `json:"favoriteActivityTypes"`
RunningTrainingSpeed float64 `json:"runningTrainingSpeed"`
CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"`
FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"`
CyclingClassification *string `json:"cyclingClassification"`
CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"`
SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"`
ProfileVisibility string `json:"profileVisibility"`
ActivityStartVisibility string `json:"activityStartVisibility"`
ActivityMapVisibility string `json:"activityMapVisibility"`
CourseVisibility string `json:"courseVisibility"`
ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"`
ActivityPowerVisibility string `json:"activityPowerVisibility"`
BadgeVisibility string `json:"badgeVisibility"`
ShowAge bool `json:"showAge"`
ShowWeight bool `json:"showWeight"`
ShowHeight bool `json:"showHeight"`
ShowWeightClass bool `json:"showWeightClass"`
ShowAgeRange bool `json:"showAgeRange"`
ShowGender bool `json:"showGender"`
ShowActivityClass bool `json:"showActivityClass"`
ShowVO2Max bool `json:"showVo2Max"`
ShowPersonalRecords bool `json:"showPersonalRecords"`
ShowLast12Months bool `json:"showLast12Months"`
ShowLifetimeTotals bool `json:"showLifetimeTotals"`
ShowUpcomingEvents bool `json:"showUpcomingEvents"`
ShowRecentFavorites bool `json:"showRecentFavorites"`
ShowRecentDevice bool `json:"showRecentDevice"`
ShowRecentGear bool `json:"showRecentGear"`
ShowBadges bool `json:"showBadges"`
OtherActivity *string `json:"otherActivity"`
OtherPrimaryActivity *string `json:"otherPrimaryActivity"`
OtherMotivation *string `json:"otherMotivation"`
UserRoles []string `json:"userRoles"`
NameApproved bool `json:"nameApproved"`
UserProfileFullName string `json:"userProfileFullName"`
MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"`
AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"`
AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"`
UserLevel int `json:"userLevel"`
UserPoint int `json:"userPoint"`
LevelUpdateDate time.Time `json:"levelUpdateDate"`
LevelIsViewed bool `json:"levelIsViewed"`
LevelPointThreshold int `json:"levelPointThreshold"`
UserPointOffset int `json:"userPointOffset"`
UserPro bool `json:"userPro"`
}

47
pkg/garth/types/auth.go Normal file
View File

@@ -0,0 +1,47 @@
package garth
import "time"
// TokenRefresher is an interface for refreshing a token.
type TokenRefresher interface {
RefreshSession() error
}
// OAuthConsumer represents OAuth consumer credentials
type OAuthConsumer struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
}
// OAuth1Token represents OAuth1 token response
type OAuth1Token struct {
OAuthToken string `json:"oauth_token"`
OAuthTokenSecret string `json:"oauth_token_secret"`
MFAToken string `json:"mfa_token,omitempty"`
Domain string `json:"domain"`
}
// OAuth2Token represents OAuth2 token response
type OAuth2Token struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
Scope string `json:"scope"`
CreatedAt time.Time // Used for expiration tracking
ExpiresAt time.Time // Computed expiration time
}
// Expired checks if token is expired
func (t *OAuth2Token) Expired() bool {
return time.Now().After(t.ExpiresAt)
}
// RefreshIfNeeded refreshes token if expired
func (t *OAuth2Token) RefreshIfNeeded(client TokenRefresher) error {
if !t.Expired() {
return nil
}
return client.RefreshSession()
}

5
pkg/garth/types/doc.go Normal file
View File

@@ -0,0 +1,5 @@
// Package garth defines core domain models mapped to Garmin Connect API JSON.
// It includes user profile, wellness metrics, sleep detail, HRV, body battery,
// training status/load, time helpers, and related structures.
// This package is intended for public use by external applications.
package garth

424
pkg/garth/types/garmin.go Normal file
View File

@@ -0,0 +1,424 @@
package garth
import (
"fmt"
"strconv"
"strings"
"time"
)
var (
// Default location for conversions (set to UTC by default)
defaultLocation *time.Location
)
func init() {
var err error
defaultLocation, err = time.LoadLocation("UTC")
if err != nil {
panic(err)
}
}
// ParseTimestamp converts a millisecond timestamp to time.Time in default location
func ParseTimestamp(ts int) time.Time {
return time.Unix(0, int64(ts)*int64(time.Millisecond)).In(defaultLocation)
}
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
func ParseAggregationKey(key, aggregate string) time.Time {
switch aggregate {
case "day":
t, _ := time.Parse("2006-01-02", key)
return t
case "week":
year, _ := strconv.Atoi(key[:4])
week, _ := strconv.Atoi(key[6:])
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
// Find the first Monday of the year
for t.Weekday() != time.Monday {
t = t.AddDate(0, 0, 1)
}
// Add weeks
return t.AddDate(0, 0, (week-1)*7)
case "month":
t, _ := time.Parse("2006-01", key)
return t
case "year":
t, _ := time.Parse("2006", key)
return t
}
return time.Time{}
}
// GarminTime represents Garmin's timestamp format with custom JSON parsing
type GarminTime struct {
time.Time
}
// UnmarshalJSON implements the json.Unmarshaler interface.
// It parses Garmin's specific timestamp format.
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
s := strings.Trim(string(b), `"`)
if s == "null" {
return nil
}
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
// If the input string does not contain 'Z', it will be parsed as local time.
// For consistency, we'll assume UTC if no timezone is specified.
layouts := []string{
"2006-01-02 15:04:05", // Example: 2025-09-21 07:18:03
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
"2006-01-02", // Example: 2018-09-01
}
for _, layout := range layouts {
if t, err := time.Parse(layout, s); err == nil {
gt.Time = t
return nil
}
}
return fmt.Errorf("cannot parse %q into a GarminTime", s)
}
// SessionData represents saved session information
type SessionData struct {
Domain string `json:"domain"`
Username string `json:"username"`
AuthToken string `json:"auth_token"`
}
// ActivityType represents the type of activity
type ActivityType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
ParentTypeID *int `json:"parentTypeId,omitempty"`
}
// EventType represents the event type of an activity
type EventType struct {
TypeID int `json:"typeId"`
TypeKey string `json:"typeKey"`
}
// Activity represents a Garmin Connect activity
type Activity struct {
ActivityID int64 `json:"activityId"`
ActivityName string `json:"activityName"`
Description string `json:"description"`
StartTimeLocal GarminTime `json:"startTimeLocal"`
StartTimeGMT GarminTime `json:"startTimeGMT"`
ActivityType ActivityType `json:"activityType"`
EventType EventType `json:"eventType"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
ElapsedDuration float64 `json:"elapsedDuration"`
MovingDuration float64 `json:"movingDuration"`
ElevationGain float64 `json:"elevationGain"`
ElevationLoss float64 `json:"elevationLoss"`
AverageSpeed float64 `json:"averageSpeed"`
MaxSpeed float64 `json:"maxSpeed"`
Calories float64 `json:"calories"`
AverageHR float64 `json:"averageHR"`
MaxHR float64 `json:"maxHR"`
}
// UserProfile represents a Garmin user profile
type UserProfile struct {
UserName string `json:"userName"`
DisplayName string `json:"displayName"`
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
// Add other fields as needed from API response
}
// VO2MaxData represents VO2 max data
type VO2MaxData struct {
Date time.Time `json:"calendarDate"`
VO2MaxRunning *float64 `json:"vo2MaxRunning"`
VO2MaxCycling *float64 `json:"vo2MaxCycling"`
UserProfilePK int `json:"userProfilePk"`
}
// Add these new structs
type VO2MaxEntry struct {
Value float64 `json:"value"`
ActivityType string `json:"activityType"` // "running" or "cycling"
Date time.Time `json:"date"`
Source string `json:"source"` // "user_settings", "activity", etc.
}
type VO2Max struct {
Value float64 `json:"vo2Max"`
FitnessLevel string `json:"fitnessLevel"`
UpdatedDate time.Time `json:"date"`
}
// VO2MaxProfile represents the current VO2 max profile from user settings
type VO2MaxProfile struct {
UserProfilePK int `json:"userProfilePk"`
LastUpdated time.Time `json:"lastUpdated"`
Running *VO2MaxEntry `json:"running,omitempty"`
Cycling *VO2MaxEntry `json:"cycling,omitempty"`
}
// SleepLevel represents different sleep stages
type SleepLevel struct {
StartGMT time.Time `json:"startGmt"`
EndGMT time.Time `json:"endGmt"`
ActivityLevel float64 `json:"activityLevel"`
SleepLevel string `json:"sleepLevel"` // "deep", "light", "rem", "awake"
}
// SleepMovement represents movement during sleep
type SleepMovement struct {
StartGMT time.Time `json:"startGmt"`
EndGMT time.Time `json:"endGmt"`
ActivityLevel float64 `json:"activityLevel"`
}
// SleepScore represents detailed sleep scoring
type SleepScore struct {
Overall int `json:"overall"`
Composition SleepScoreBreakdown `json:"composition"`
Revitalization SleepScoreBreakdown `json:"revitalization"`
Duration SleepScoreBreakdown `json:"duration"`
DeepPercentage float64 `json:"deepPercentage"`
LightPercentage float64 `json:"lightPercentage"`
RemPercentage float64 `json:"remPercentage"`
RestfulnessValue float64 `json:"restfulnessValue"`
}
type SleepScoreBreakdown struct {
QualifierKey string `json:"qualifierKey"`
OptimalStart float64 `json:"optimalStart"`
OptimalEnd float64 `json:"optimalEnd"`
Value float64 `json:"value"`
IdealStartSecs *int `json:"idealStartInSeconds"`
IdealEndSecs *int `json:"idealEndInSeconds"`
}
// DetailedSleepData represents comprehensive sleep data
type DetailedSleepData struct {
UserProfilePK int `json:"userProfilePk"`
CalendarDate time.Time `json:"calendarDate"`
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
SleepStartTimestampLocal time.Time `json:"sleepStartTimestampLocal"`
SleepEndTimestampLocal time.Time `json:"sleepEndTimestampLocal"`
UnmeasurableSleepSeconds int `json:"unmeasurableSleepSeconds"`
DeepSleepSeconds int `json:"deepSleepSeconds"`
LightSleepSeconds int `json:"lightSleepSeconds"`
RemSleepSeconds int `json:"remSleepSeconds"`
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
DeviceRemCapable bool `json:"deviceRemCapable"`
SleepLevels []SleepLevel `json:"sleepLevels"`
SleepMovement []SleepMovement `json:"sleepMovement"`
SleepScores *SleepScore `json:"sleepScores"`
AverageSpO2Value *float64 `json:"averageSpO2Value"`
LowestSpO2Value *int `json:"lowestSpO2Value"`
HighestSpO2Value *int `json:"highestSpO2Value"`
AverageRespirationValue *float64 `json:"averageRespirationValue"`
LowestRespirationValue *float64 `json:"lowestRespirationValue"`
HighestRespirationValue *float64 `json:"highestRespirationValue"`
AvgSleepStress *float64 `json:"avgSleepStress"`
}
// HRVBaseline represents HRV baseline data
type HRVBaseline struct {
LowUpper int `json:"lowUpper"`
BalancedLow int `json:"balancedLow"`
BalancedUpper int `json:"balancedUpper"`
MarkerValue float64 `json:"markerValue"`
}
// DailyHRVData represents comprehensive daily HRV data
type DailyHRVData struct {
UserProfilePK int `json:"userProfilePk"`
CalendarDate time.Time `json:"calendarDate"`
WeeklyAvg *float64 `json:"weeklyAvg"`
LastNightAvg *float64 `json:"lastNightAvg"`
LastNight5MinHigh *float64 `json:"lastNight5MinHigh"`
Baseline HRVBaseline `json:"baseline"`
Status string `json:"status"`
FeedbackPhrase string `json:"feedbackPhrase"`
CreateTimeStamp time.Time `json:"createTimeStamp"`
HRVReadings []HRVReading `json:"hrvReadings"`
StartTimestampGMT time.Time `json:"startTimestampGmt"`
EndTimestampGMT time.Time `json:"endTimestampGmt"`
StartTimestampLocal time.Time `json:"startTimestampLocal"`
EndTimestampLocal time.Time `json:"endTimestampLocal"`
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
}
// BodyBatteryEvent represents events that impact Body Battery
type BodyBatteryEvent struct {
EventType string `json:"eventType"` // "sleep", "activity", "stress"
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
TimezoneOffset int `json:"timezoneOffset"`
DurationInMilliseconds int `json:"durationInMilliseconds"`
BodyBatteryImpact int `json:"bodyBatteryImpact"`
FeedbackType string `json:"feedbackType"`
ShortFeedback string `json:"shortFeedback"`
}
// DetailedBodyBatteryData represents comprehensive Body Battery data
type DetailedBodyBatteryData struct {
UserProfilePK int `json:"userProfilePk"`
CalendarDate time.Time `json:"calendarDate"`
StartTimestampGMT time.Time `json:"startTimestampGmt"`
EndTimestampGMT time.Time `json:"endTimestampGmt"`
StartTimestampLocal time.Time `json:"startTimestampLocal"`
EndTimestampLocal time.Time `json:"endTimestampLocal"`
MaxStressLevel int `json:"maxStressLevel"`
AvgStressLevel int `json:"avgStressLevel"`
BodyBatteryValuesArray [][]interface{} `json:"bodyBatteryValuesArray"`
StressValuesArray [][]int `json:"stressValuesArray"`
Events []BodyBatteryEvent `json:"bodyBatteryEvents"`
}
// TrainingStatus represents current training status
type TrainingStatus struct {
CalendarDate time.Time `json:"calendarDate"`
TrainingStatusKey string `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
TrainingStatusTypeKey string `json:"trainingStatusTypeKey"`
TrainingStatusValue int `json:"trainingStatusValue"`
LoadRatio float64 `json:"loadRatio"`
}
// TrainingLoad represents training load data
type TrainingLoad struct {
CalendarDate time.Time `json:"calendarDate"`
AcuteTrainingLoad float64 `json:"acuteTrainingLoad"`
ChronicTrainingLoad float64 `json:"chronicTrainingLoad"`
TrainingLoadRatio float64 `json:"trainingLoadRatio"`
TrainingEffectAerobic float64 `json:"trainingEffectAerobic"`
TrainingEffectAnaerobic float64 `json:"trainingEffectAnaerobic"`
}
// FitnessAge represents fitness age calculation
type FitnessAge struct {
FitnessAge int `json:"fitnessAge"`
ChronologicalAge int `json:"chronologicalAge"`
VO2MaxRunning float64 `json:"vo2MaxRunning"`
LastUpdated time.Time `json:"lastUpdated"`
}
// HeartRateZones represents heart rate zone data
type HeartRateZones struct {
RestingHR int `json:"resting_hr"`
MaxHR int `json:"max_hr"`
LactateThreshold int `json:"lactate_threshold"`
Zones []HRZone `json:"zones"`
UpdatedAt time.Time `json:"updated_at"`
}
// HRZone represents a single heart rate zone
type HRZone struct {
Zone int `json:"zone"`
MinBPM int `json:"min_bpm"`
MaxBPM int `json:"max_bpm"`
Name string `json:"name"`
}
// WellnessData represents additional wellness metrics
type WellnessData struct {
Date time.Time `json:"calendarDate"`
RestingHR *int `json:"resting_hr"`
Weight *float64 `json:"weight"`
BodyFat *float64 `json:"body_fat"`
BMI *float64 `json:"bmi"`
BodyWater *float64 `json:"body_water"`
BoneMass *float64 `json:"bone_mass"`
MuscleMass *float64 `json:"muscle_mass"`
// Add more fields as needed
}
// SleepData represents sleep summary data
type SleepData struct {
Date time.Time `json:"calendarDate"`
SleepScore int `json:"sleepScore"`
TotalSleepSeconds int `json:"totalSleepSeconds"`
DeepSleepSeconds int `json:"deepSleepSeconds"`
LightSleepSeconds int `json:"lightSleepSeconds"`
RemSleepSeconds int `json:"remSleepSeconds"`
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
// Add more fields as needed
}
// HrvData represents Heart Rate Variability data
type HrvData struct {
Date time.Time `json:"calendarDate"`
HrvValue float64 `json:"hrvValue"`
// Add more fields as needed
}
// HRVStatus represents HRV status and baseline
type HRVStatus struct {
Status string `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
FeedbackPhrase string `json:"feedbackPhrase"`
BaselineLowUpper int `json:"baselineLowUpper"`
BalancedLow int `json:"balancedLow"`
BalancedUpper int `json:"balancedUpper"`
MarkerValue float64 `json:"markerValue"`
}
// HRVReading represents an individual HRV reading
type HRVReading struct {
Timestamp int `json:"timestamp"`
StressLevel int `json:"stressLevel"`
HeartRate int `json:"heartRate"`
RRInterval int `json:"rrInterval"`
Status string `json:"status"`
SignalQuality float64 `json:"signalQuality"`
}
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
func (r *HRVReading) TimestampAsTime() time.Time {
return ParseTimestamp(r.Timestamp)
}
// RRSeconds converts the RR interval to seconds
func (r *HRVReading) RRSeconds() float64 {
return float64(r.RRInterval) / 1000.0
}
// StressData represents stress level data
type StressData struct {
Date time.Time `json:"calendarDate"`
StressLevel int `json:"stressLevel"`
RestStressLevel int `json:"restStressLevel"`
// Add more fields as needed
}
// BodyBatteryData represents Body Battery data
type BodyBatteryData struct {
Date time.Time `json:"calendarDate"`
BatteryLevel int `json:"batteryLevel"`
Charge int `json:"charge"`
Drain int `json:"drain"`
// Add more fields as needed
}
// StepsData represents steps statistics
type StepsData struct {
Date time.Time `json:"calendarDate"`
Steps int `json:"steps"`
}
// DistanceData represents distance statistics
type DistanceData struct {
Date time.Time `json:"calendarDate"`
Distance float64 `json:"distance"` // in meters
}
// CaloriesData represents calories statistics
type CaloriesData struct {
Date time.Time `json:"calendarDate"`
Calories int `json:"activeCalories"`
}

View File

@@ -0,0 +1,82 @@
package garth
import (
"encoding/json"
"testing"
"time"
)
func TestGarminTime_UnmarshalJSON(t *testing.T) {
tests := []struct {
name string
input string
expected time.Time
wantErr bool
}{
{
name: "space separated format",
input: `"2025-09-21 07:18:03"`,
expected: time.Date(2025, 9, 21, 7, 18, 3, 0, time.UTC),
wantErr: false,
},
{
name: "T separator with milliseconds",
input: `"2018-09-01T00:13:25.0"`,
expected: time.Date(2018, 9, 1, 0, 13, 25, 0, time.UTC),
wantErr: false,
},
{
name: "T separator without milliseconds",
input: `"2018-09-01T00:13:25"`,
expected: time.Date(2018, 9, 1, 0, 13, 25, 0, time.UTC),
wantErr: false,
},
{
name: "date only",
input: `"2018-09-01"`,
expected: time.Date(2018, 9, 1, 0, 0, 0, 0, time.UTC),
wantErr: false,
},
{
name: "invalid format",
input: `"invalid"`,
wantErr: true,
},
{
name: "null value",
input: "null",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gt GarminTime
err := json.Unmarshal([]byte(tt.input), &gt)
if tt.wantErr {
if err == nil {
t.Errorf("expected error but got none")
}
return
}
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if tt.input == "null" {
// For null values, the time should be zero
if !gt.Time.IsZero() {
t.Errorf("expected zero time for null input, got %v", gt.Time)
}
return
}
if !gt.Time.Equal(tt.expected) {
t.Errorf("expected %v, got %v", tt.expected, gt.Time)
}
})
}
}