This commit is contained in:
2025-09-18 11:12:41 -07:00
parent c00ea67f31
commit 026d8873bb
56 changed files with 2321 additions and 2891 deletions

View File

@@ -1,11 +1,11 @@
package garth
import (
"garmin-connect/garth/client"
"garmin-connect/garth/data"
"garmin-connect/garth/errors"
"garmin-connect/garth/stats"
"garmin-connect/garth/types"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/data"
"github.com/sstent/go-garth/garth/errors"
"github.com/sstent/go-garth/garth/stats"
"github.com/sstent/go-garth/garth/types"
)
// Re-export main types for convenience

View File

@@ -2,11 +2,12 @@ package garth_test
import (
"encoding/json"
"garmin-connect/garth/client"
"garmin-connect/garth/data"
"garmin-connect/garth/testutils"
"testing"
"time"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/data"
"github.com/sstent/go-garth/garth/testutils"
)
func BenchmarkBodyBatteryGet(b *testing.B) {

View File

@@ -5,13 +5,13 @@ import (
"net/url"
"testing"
"garmin-connect/garth/testutils"
"github.com/sstent/go-garth/garth/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"garmin-connect/garth/client"
"garmin-connect/garth/errors"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/errors"
)
func TestClient_Login_Success(t *testing.T) {

View File

@@ -1,448 +0,0 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"garmin-connect/garth/errors"
"garmin-connect/garth/sso"
"garmin-connect/garth/types"
)
// Client represents the Garmin Connect API client
type Client struct {
Domain string
HTTPClient *http.Client
Username string
AuthToken string
OAuth1Token *types.OAuth1Token
OAuth2Token *types.OAuth2Token
}
// 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(host)
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
}
// GetUserProfile retrieves the current user's full profile
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.Domain)
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 types.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) {
u := &url.URL{
Scheme: "https",
Host: fmt.Sprintf("connectapi.%s", 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, filePath string) error {
params := url.Values{}
params.Add("activityId", activityID)
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) ([]types.Activity, error) {
if limit <= 0 {
limit = 10
}
activitiesURL := fmt.Sprintf("https://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", 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 []types.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
}
// SaveSession saves the current session to a file
func (c *Client) SaveSession(filename string) error {
session := types.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
}
// 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 types.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
}

View File

@@ -5,12 +5,12 @@ import (
"testing"
"time"
"garmin-connect/garth/testutils"
"github.com/sstent/go-garth/garth/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
func TestClient_GetUserProfile(t *testing.T) {

View File

@@ -5,8 +5,8 @@ import (
"sync"
"time"
"garmin-connect/garth/client"
"garmin-connect/garth/utils"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/utils"
)
// Data defines the interface for Garmin Connect data types.

View File

@@ -5,7 +5,7 @@ import (
"testing"
"time"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
"github.com/stretchr/testify/assert"
)

View File

@@ -6,7 +6,7 @@ import (
"sort"
"time"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
// DailyBodyBatteryStress represents complete daily Body Battery and stress data

View File

@@ -7,8 +7,8 @@ import (
"sort"
"time"
"garmin-connect/garth/client"
"garmin-connect/garth/utils"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/utils"
)
// HRVSummary represents Heart Rate Variability summary data

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
// SleepScores represents sleep scoring data

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"time"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
// WeightData represents weight measurement data

View File

@@ -1,11 +1,11 @@
package garth
import (
"garmin-connect/garth/client"
"garmin-connect/garth/data"
"garmin-connect/garth/errors"
"garmin-connect/garth/stats"
"garmin-connect/garth/types"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/data"
"github.com/sstent/go-garth/garth/errors"
"github.com/sstent/go-garth/garth/stats"
"github.com/sstent/go-garth/garth/types"
)
// Client is the main Garmin Connect client type

View File

@@ -4,9 +4,9 @@ import (
"testing"
"time"
"garmin-connect/garth/client"
"garmin-connect/garth/data"
"garmin-connect/garth/stats"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/data"
"github.com/sstent/go-garth/garth/stats"
)
func TestBodyBatteryIntegration(t *testing.T) {

View File

@@ -9,8 +9,8 @@ import (
"strings"
"time"
"garmin-connect/garth/types"
"garmin-connect/garth/utils"
"github.com/sstent/go-garth/garth/types"
"github.com/sstent/go-garth/garth/utils"
)
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket

View File

@@ -1,260 +0,0 @@
package sso
import (
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"garmin-connect/garth/oauth"
"garmin-connect/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)
// 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("https://sso.%s/sso/signin?%s", 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 ""
}

View File

@@ -6,8 +6,8 @@ import (
"strings"
"time"
"garmin-connect/garth/client"
"garmin-connect/garth/utils"
"github.com/sstent/go-garth/garth/client"
"github.com/sstent/go-garth/garth/utils"
)
type Stats interface {

View File

@@ -5,7 +5,7 @@ import (
"io"
"net/url"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
// MockClient simulates API client for tests

View File

@@ -1,111 +0,0 @@
package types
import (
"encoding/json"
"net/http"
"time"
)
// GarminTime represents Garmin's timestamp format with custom JSON parsing
type GarminTime struct {
time.Time
}
// UnmarshalJSON handles Garmin's timestamp format
func (gt *GarminTime) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
t, err := time.Parse("2006-01-02T15:04:05", s)
if err != nil {
return err
}
gt.Time = t
return nil
}
// Client represents the Garmin Connect client
type Client struct {
Domain string
HTTPClient *http.Client
Username string
AuthToken string
OAuth1Token *OAuth1Token
OAuth2Token *OAuth2Token
}
// 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 string `json:"startTimeLocal"`
StartTimeGMT string `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"`
}
// 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"`
}
// 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
}
// 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
}
// OAuthConsumer represents OAuth consumer credentials
type OAuthConsumer struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
}

View File

@@ -3,7 +3,7 @@ package users
import (
"time"
"garmin-connect/garth/client"
"github.com/sstent/go-garth/garth/client"
)
type PowerFormat struct {

View File

@@ -6,7 +6,6 @@ import (
"crypto/sha1"
"encoding/base64"
"encoding/json"
"garmin-connect/garth/types"
"net/http"
"net/url"
"regexp"
@@ -14,6 +13,8 @@ import (
"strconv"
"strings"
"time"
"github.com/sstent/go-garth/garth/types"
)
var oauthConsumer *types.OAuthConsumer