mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-31 19:42:01 +00:00
reworked api interfaces
This commit is contained in:
@@ -9,12 +9,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/models/types"
|
|
||||||
"github.com/sstent/go-garth/internal/utils"
|
"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
|
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||||
func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
func GetOAuth1Token(domain, ticket string) (*garth.OAuth1Token, error) {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if strings.HasPrefix(domain, "127.0.0.1") {
|
if strings.HasPrefix(domain, "127.0.0.1") {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -85,7 +85,7 @@ func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
|||||||
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
|
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &types.OAuth1Token{
|
return &garth.OAuth1Token{
|
||||||
OAuthToken: oauthToken,
|
OAuthToken: oauthToken,
|
||||||
OAuthTokenSecret: oauthTokenSecret,
|
OAuthTokenSecret: oauthTokenSecret,
|
||||||
MFAToken: values.Get("mfa_token"),
|
MFAToken: values.Get("mfa_token"),
|
||||||
@@ -94,7 +94,7 @@ func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
||||||
func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
func ExchangeToken(oauth1Token *garth.OAuth1Token) (*garth.OAuth2Token, error) {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
|
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -148,7 +148,7 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
|||||||
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
var oauth2Token types.OAuth2Token
|
var oauth2Token garth.OAuth2Token
|
||||||
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
||||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/auth/oauth"
|
"github.com/sstent/go-garth/internal/auth/oauth"
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -41,7 +41,7 @@ func NewClient(domain string) *Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login performs the SSO authentication flow
|
// Login performs the SSO authentication flow
|
||||||
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
|
func (c *Client) Login(email, password string) (*garth.OAuth2Token, *MFAContext, error) {
|
||||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
||||||
|
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
@@ -185,7 +185,7 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResumeLogin completes authentication after MFA challenge
|
// ResumeLogin completes authentication after MFA challenge
|
||||||
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
|
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*garth.OAuth2Token, error) {
|
||||||
fmt.Println("Resuming login with MFA code...")
|
fmt.Println("Resuming login with MFA code...")
|
||||||
|
|
||||||
// Submit MFA form
|
// Submit MFA form
|
||||||
|
|||||||
7
internal/data/base.go
Normal file
7
internal/data/base.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
|
|
||||||
|
// Alias shared BaseData and Data into internal/data for backward compatibility with tests
|
||||||
|
type BaseData = shared.BaseData
|
||||||
|
type Data = shared.Data
|
||||||
@@ -5,7 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
"github.com/sstent/go-garth/pkg/garth/client"
|
||||||
|
interfaces "github.com/sstent/go-garth/shared/interfaces"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -28,7 +29,7 @@ func (mc *MockClient) Get(endpoint string) (interface{}, error) {
|
|||||||
func TestBaseData_List(t *testing.T) {
|
func TestBaseData_List(t *testing.T) {
|
||||||
// Setup mock data type
|
// Setup mock data type
|
||||||
mockData := &MockData{}
|
mockData := &MockData{}
|
||||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
mockData.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||||
return "data for " + day.Format("2006-01-02"), nil
|
return "data for " + day.Format("2006-01-02"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ func TestBaseData_List(t *testing.T) {
|
|||||||
func TestBaseData_List_ErrorHandling(t *testing.T) {
|
func TestBaseData_List_ErrorHandling(t *testing.T) {
|
||||||
// Setup mock data type that returns error on specific date
|
// Setup mock data type that returns error on specific date
|
||||||
mockData := &MockData{}
|
mockData := &MockData{}
|
||||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
mockData.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||||
if day.Day() == 13 {
|
if day.Day() == 13 {
|
||||||
return nil, errors.New("bad luck day")
|
return nil, errors.New("bad luck day")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,38 +19,92 @@ type BodyBatteryReading struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
||||||
|
// Accepts mixed numeric types (int, int64, float64, json.Number) for robustness.
|
||||||
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||||
readings := make([]BodyBatteryReading, 0)
|
readings := make([]BodyBatteryReading, 0, len(valuesArray))
|
||||||
|
|
||||||
|
toInt := func(v any) (int, bool) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case int:
|
||||||
|
return t, true
|
||||||
|
case int32:
|
||||||
|
return int(t), true
|
||||||
|
case int64:
|
||||||
|
return int(t), true
|
||||||
|
case float32:
|
||||||
|
return int(t), true
|
||||||
|
case float64:
|
||||||
|
return int(t), true
|
||||||
|
case json.Number:
|
||||||
|
i, err := t.Int64()
|
||||||
|
if err == nil {
|
||||||
|
return int(i), true
|
||||||
|
}
|
||||||
|
f, err := t.Float64()
|
||||||
|
if err == nil {
|
||||||
|
return int(f), true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toFloat64 := func(v any) (float64, bool) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case float32:
|
||||||
|
return float64(t), true
|
||||||
|
case float64:
|
||||||
|
return t, true
|
||||||
|
case int:
|
||||||
|
return float64(t), true
|
||||||
|
case int32:
|
||||||
|
return float64(t), true
|
||||||
|
case int64:
|
||||||
|
return float64(t), true
|
||||||
|
case json.Number:
|
||||||
|
f, err := t.Float64()
|
||||||
|
if err == nil {
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, values := range valuesArray {
|
for _, values := range valuesArray {
|
||||||
if len(values) < 4 {
|
if len(values) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp, ok1 := values[0].(float64)
|
ts, ok1 := toInt(values[0])
|
||||||
status, ok2 := values[1].(string)
|
status, ok2 := values[1].(string)
|
||||||
level, ok3 := values[2].(float64)
|
lvl, ok3 := toInt(values[2])
|
||||||
version, ok4 := values[3].(float64)
|
ver, ok4 := toFloat64(values[3])
|
||||||
|
|
||||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
readings = append(readings, BodyBatteryReading{
|
readings = append(readings, BodyBatteryReading{
|
||||||
Timestamp: int(timestamp),
|
Timestamp: ts,
|
||||||
Status: status,
|
Status: status,
|
||||||
Level: int(level),
|
Level: lvl,
|
||||||
Version: version,
|
Version: ver,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.Slice(readings, func(i, j int) bool {
|
sort.Slice(readings, func(i, j int) bool {
|
||||||
return readings[i].Timestamp < readings[j].Timestamp
|
return readings[i].Timestamp < readings[j].Timestamp
|
||||||
})
|
})
|
||||||
|
|
||||||
return readings
|
return readings
|
||||||
}
|
}
|
||||||
|
|
||||||
// BodyBatteryDataWithMethods embeds types.DetailedBodyBatteryData and adds methods
|
// BodyBatteryDataWithMethods embeds garth.DetailedBodyBatteryData and adds methods
|
||||||
type BodyBatteryDataWithMethods struct {
|
type BodyBatteryDataWithMethods struct {
|
||||||
types.DetailedBodyBatteryData
|
garth.DetailedBodyBatteryData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||||
@@ -71,14 +125,14 @@ func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (int
|
|||||||
data2 = []byte("[]")
|
data2 = []byte("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
var result types.DetailedBodyBatteryData
|
var result garth.DetailedBodyBatteryData
|
||||||
if len(data1) > 0 {
|
if len(data1) > 0 {
|
||||||
if err := json.Unmarshal(data1, &result); err != nil {
|
if err := json.Unmarshal(data1, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var events []types.BodyBatteryEvent
|
var events []garth.BodyBatteryEvent
|
||||||
if len(data2) > 0 {
|
if len(data2) > 0 {
|
||||||
if err := json.Unmarshal(data2, &events); err == nil {
|
if err := json.Unmarshal(data2, &events); err == nil {
|
||||||
result.Events = events
|
result.Events = events
|
||||||
@@ -111,3 +165,48 @@ func (d *BodyBatteryDataWithMethods) GetDayChange() int {
|
|||||||
|
|
||||||
return readings[len(readings)-1].Level - readings[0].Level
|
return readings[len(readings)-1].Level - readings[0].Level
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Added for test compatibility and public API alignment
|
||||||
|
// DailyBodyBatteryStress wraps garth.DetailedBodyBatteryData and provides a Get method compatible with existing tests.
|
||||||
|
// See [type DailyBodyBatteryStress](internal/data/body_battery.go:0) and [func (*DailyBodyBatteryStress).Get](internal/data/body_battery.go:0)
|
||||||
|
type DailyBodyBatteryStress struct {
|
||||||
|
garth.DetailedBodyBatteryData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves Body Battery daily stress data and associated events for a given day.
|
||||||
|
// Mirrors logic in BodyBatteryDataWithMethods.Get to maintain a consistent behavior.
|
||||||
|
// Returns (*DailyBodyBatteryStress, nil) on success, (nil, nil) when no data available.
|
||||||
|
func (d *DailyBodyBatteryStress) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||||
|
dateStr := day.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 &DailyBodyBatteryStress{DetailedBodyBatteryData: result}, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ func TestParseBodyBatteryReadings(t *testing.T) {
|
|||||||
|
|
||||||
// Test for GetCurrentLevel and GetDayChange methods
|
// Test for GetCurrentLevel and GetDayChange methods
|
||||||
func TestBodyBatteryDataWithMethods(t *testing.T) {
|
func TestBodyBatteryDataWithMethods(t *testing.T) {
|
||||||
mockData := types.DetailedBodyBatteryData{
|
mockData := garth.DetailedBodyBatteryData{
|
||||||
BodyBatteryValuesArray: [][]interface{}{
|
BodyBatteryValuesArray: [][]interface{}{
|
||||||
{1000, "ACTIVE", 75, 1.0},
|
{1000, "ACTIVE", 75, 1.0},
|
||||||
{2000, "ACTIVE", 70, 1.0},
|
{2000, "ACTIVE", 70, 1.0},
|
||||||
@@ -72,7 +72,7 @@ func TestBodyBatteryDataWithMethods(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Test with empty data
|
// Test with empty data
|
||||||
emptyData := types.DetailedBodyBatteryData{
|
emptyData := garth.DetailedBodyBatteryData{
|
||||||
BodyBatteryValuesArray: [][]interface{}{},
|
BodyBatteryValuesArray: [][]interface{}{},
|
||||||
}
|
}
|
||||||
emptyBb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: emptyData}
|
emptyBb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: emptyData}
|
||||||
@@ -86,7 +86,7 @@ func TestBodyBatteryDataWithMethods(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Test with single reading
|
// Test with single reading
|
||||||
singleReadingData := types.DetailedBodyBatteryData{
|
singleReadingData := garth.DetailedBodyBatteryData{
|
||||||
BodyBatteryValuesArray: [][]interface{}{
|
BodyBatteryValuesArray: [][]interface{}{
|
||||||
{1000, "ACTIVE", 80, 1.0},
|
{1000, "ACTIVE", 80, 1.0},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyHRVDataWithMethods embeds types.DailyHRVData and adds methods
|
// DailyHRVDataWithMethods embeds garth.DailyHRVData and adds methods
|
||||||
type DailyHRVDataWithMethods struct {
|
type DailyHRVDataWithMethods struct {
|
||||||
types.DailyHRVData
|
garth.DailyHRVData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements the Data interface for DailyHRVData
|
// Get implements the Data interface for DailyHRVData
|
||||||
@@ -31,8 +31,8 @@ func (h *DailyHRVDataWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
HRVSummary garth.DailyHRVData `json:"hrvSummary"`
|
||||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
HRVReadings []garth.HRVReading `json:"hrvReadings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &response); err != nil {
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
@@ -45,8 +45,8 @@ func (h *DailyHRVDataWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ParseHRVReadings converts body battery values array to structured readings
|
// ParseHRVReadings converts body battery values array to structured readings
|
||||||
func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
func ParseHRVReadings(valuesArray [][]any) []garth.HRVReading {
|
||||||
readings := make([]types.HRVReading, 0, len(valuesArray))
|
readings := make([]garth.HRVReading, 0, len(valuesArray))
|
||||||
for _, values := range valuesArray {
|
for _, values := range valuesArray {
|
||||||
if len(values) < 6 {
|
if len(values) < 6 {
|
||||||
continue
|
continue
|
||||||
@@ -60,7 +60,7 @@ func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
|||||||
status, _ := values[4].(string)
|
status, _ := values[4].(string)
|
||||||
signalQuality, _ := values[5].(float64)
|
signalQuality, _ := values[5].(float64)
|
||||||
|
|
||||||
readings = append(readings, types.HRVReading{
|
readings = append(readings, garth.HRVReading{
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
StressLevel: stressLevel,
|
StressLevel: stressLevel,
|
||||||
HeartRate: heartRate,
|
HeartRate: heartRate,
|
||||||
|
|||||||
@@ -5,17 +5,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailySleepDTO represents daily sleep data
|
// DailySleepDTO represents daily sleep data
|
||||||
type DailySleepDTO struct {
|
type DailySleepDTO struct {
|
||||||
UserProfilePK int `json:"userProfilePk"`
|
UserProfilePK int `json:"userProfilePk"`
|
||||||
CalendarDate time.Time `json:"calendarDate"`
|
CalendarDate time.Time `json:"calendarDate"`
|
||||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||||
SleepScores types.SleepScore `json:"sleepScores"` // Using types.SleepScore
|
SleepScores garth.SleepScore `json:"sleepScores"` // Using garth.SleepScore
|
||||||
shared.BaseData
|
shared.BaseData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ func (d *DailySleepDTO) Get(day time.Time, c shared.APIClient) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||||
SleepMovement []types.SleepMovement `json:"sleepMovement"` // Using types.SleepMovement
|
SleepMovement []garth.SleepMovement `json:"sleepMovement"` // Using garth.SleepMovement
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(data, &response); err != nil {
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DetailedSleepDataWithMethods embeds types.DetailedSleepData and adds methods
|
// DetailedSleepDataWithMethods embeds garth.DetailedSleepData and adds methods
|
||||||
type DetailedSleepDataWithMethods struct {
|
type DetailedSleepDataWithMethods struct {
|
||||||
types.DetailedSleepData
|
garth.DetailedSleepData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||||
@@ -29,10 +29,10 @@ func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (i
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
DailySleepDTO *garth.DetailedSleepData `json:"dailySleepDTO"`
|
||||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
SleepMovement []garth.SleepMovement `json:"sleepMovement"`
|
||||||
RemSleepData bool `json:"remSleepData"`
|
RemSleepData bool `json:"remSleepData"`
|
||||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
SleepLevels []garth.SleepLevel `json:"sleepLevels"`
|
||||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||||
@@ -58,8 +58,8 @@ func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (i
|
|||||||
|
|
||||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||||
func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
||||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
totalTime := d.DetailedSleepData.SleepEndTimestampGMT.Sub(d.DetailedSleepData.SleepStartTimestampGMT).Seconds()
|
||||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
sleepTime := float64(d.DetailedSleepData.DeepSleepSeconds + d.DetailedSleepData.LightSleepSeconds + d.DetailedSleepData.RemSleepSeconds)
|
||||||
if totalTime == 0 {
|
if totalTime == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -68,6 +68,6 @@ func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
|||||||
|
|
||||||
// GetTotalSleepTime returns total sleep time in hours
|
// GetTotalSleepTime returns total sleep time in hours
|
||||||
func (d *DetailedSleepDataWithMethods) GetTotalSleepTime() float64 {
|
func (d *DetailedSleepDataWithMethods) GetTotalSleepTime() float64 {
|
||||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
totalSeconds := d.DetailedSleepData.DeepSleepSeconds + d.DetailedSleepData.LightSleepSeconds + d.DetailedSleepData.RemSleepSeconds
|
||||||
return float64(totalSeconds) / 3600.0
|
return float64(totalSeconds) / 3600.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TrainingStatusWithMethods embeds types.TrainingStatus and adds methods
|
// TrainingStatusWithMethods embeds garth.TrainingStatus and adds methods
|
||||||
type TrainingStatusWithMethods struct {
|
type TrainingStatusWithMethods struct {
|
||||||
types.TrainingStatus
|
garth.TrainingStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||||
@@ -27,7 +27,7 @@ func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (inte
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result types.TrainingStatus
|
var result garth.TrainingStatus
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -35,9 +35,9 @@ func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (inte
|
|||||||
return &TrainingStatusWithMethods{TrainingStatus: result}, nil
|
return &TrainingStatusWithMethods{TrainingStatus: result}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TrainingLoadWithMethods embeds types.TrainingLoad and adds methods
|
// TrainingLoadWithMethods embeds garth.TrainingLoad and adds methods
|
||||||
type TrainingLoadWithMethods struct {
|
type TrainingLoadWithMethods struct {
|
||||||
types.TrainingLoad
|
garth.TrainingLoad
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||||
@@ -54,7 +54,7 @@ func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []types.TrainingLoad
|
var results []garth.TrainingLoad
|
||||||
if err := json.Unmarshal(data, &results); err != nil {
|
if err := json.Unmarshal(data, &results); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||||
@@ -29,14 +29,14 @@ func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract VO2 max data from user settings
|
// Extract VO2 max data from user settings
|
||||||
vo2Profile := &types.VO2MaxProfile{
|
vo2Profile := &garth.VO2MaxProfile{
|
||||||
UserProfilePK: settings.ID,
|
UserProfilePK: settings.ID,
|
||||||
LastUpdated: time.Now(),
|
LastUpdated: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add running VO2 max if available
|
// Add running VO2 max if available
|
||||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||||
vo2Profile.Running = &types.VO2MaxEntry{
|
vo2Profile.Running = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxRunning,
|
Value: *settings.UserData.VO2MaxRunning,
|
||||||
ActivityType: "running",
|
ActivityType: "running",
|
||||||
Date: day,
|
Date: day,
|
||||||
@@ -46,7 +46,7 @@ func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error)
|
|||||||
|
|
||||||
// Add cycling VO2 max if available
|
// Add cycling VO2 max if available
|
||||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
vo2Profile.Cycling = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxCycling,
|
Value: *settings.UserData.VO2MaxCycling,
|
||||||
ActivityType: "cycling",
|
ActivityType: "cycling",
|
||||||
Date: day,
|
Date: day,
|
||||||
@@ -77,14 +77,14 @@ func (v *VO2MaxData) List(end time.Time, days int, c shared.APIClient, maxWorker
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
||||||
func GetCurrentVO2Max(c shared.APIClient) (*types.VO2MaxProfile, error) {
|
func GetCurrentVO2Max(c shared.APIClient) (*garth.VO2MaxProfile, error) {
|
||||||
vo2Data := NewVO2MaxData()
|
vo2Data := NewVO2MaxData()
|
||||||
result, err := vo2Data.get(time.Now(), c)
|
result, err := vo2Data.get(time.Now(), c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
vo2Profile, ok := result.(*types.VO2MaxProfile)
|
vo2Profile, ok := result.(*garth.VO2MaxProfile)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unexpected result type")
|
return nil, fmt.Errorf("unexpected result type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
"github.com/sstent/go-garth/shared/interfaces"
|
"github.com/sstent/go-garth/shared/interfaces"
|
||||||
"github.com/sstent/go-garth/shared/models"
|
"github.com/sstent/go-garth/shared/models"
|
||||||
|
|
||||||
@@ -27,13 +27,13 @@ func TestVO2MaxData_Get(t *testing.T) {
|
|||||||
|
|
||||||
// Mock the get function
|
// Mock the get function
|
||||||
vo2Data.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
vo2Data.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||||
vo2Profile := &types.VO2MaxProfile{
|
vo2Profile := &garth.VO2MaxProfile{
|
||||||
UserProfilePK: settings.ID,
|
UserProfilePK: settings.ID,
|
||||||
LastUpdated: time.Now(),
|
LastUpdated: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||||
vo2Profile.Running = &types.VO2MaxEntry{
|
vo2Profile.Running = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxRunning,
|
Value: *settings.UserData.VO2MaxRunning,
|
||||||
ActivityType: "running",
|
ActivityType: "running",
|
||||||
Date: day,
|
Date: day,
|
||||||
@@ -42,7 +42,7 @@ func TestVO2MaxData_Get(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
vo2Profile.Cycling = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxCycling,
|
Value: *settings.UserData.VO2MaxCycling,
|
||||||
ActivityType: "cycling",
|
ActivityType: "cycling",
|
||||||
Date: day,
|
Date: day,
|
||||||
@@ -59,7 +59,7 @@ func TestVO2MaxData_Get(t *testing.T) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, result)
|
assert.NotNil(t, result)
|
||||||
|
|
||||||
profile, ok := result.(*types.VO2MaxProfile)
|
profile, ok := result.(*garth.VO2MaxProfile)
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
assert.Equal(t, 12345, profile.UserProfilePK)
|
assert.Equal(t, 12345, profile.UserProfilePK)
|
||||||
assert.NotNil(t, profile.Running)
|
assert.NotNil(t, profile.Running)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (w *WeightDataWithMethods) Get(day time.Time, c shared.APIClient) (any, err
|
|||||||
|
|
||||||
// List implements the Data interface for concurrent fetching
|
// List implements the Data interface for concurrent fetching
|
||||||
func (w *WeightDataWithMethods) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
func (w *WeightDataWithMethods) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||||
// BaseData is not part of types.WeightData, so this line needs to be removed or re-evaluated.
|
// BaseData is not part of garth.WeightData, so this line needs to be removed or re-evaluated.
|
||||||
// For now, I will return an empty slice and no error, as this function is not directly related to the task.
|
// For now, I will return an empty slice and no error, as this function is not directly related to the task.
|
||||||
return []any{}, nil
|
return []any{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
"github.com/sstent/go-garth/pkg/garth/client"
|
||||||
"github.com/sstent/go-garth/internal/utils"
|
"github.com/sstent/go-garth/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
"github.com/sstent/go-garth/pkg/garth/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockClient simulates API client for tests
|
// MockClient simulates API client for tests
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package users
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
"github.com/sstent/go-garth/pkg/garth/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PowerFormat struct {
|
type PowerFormat struct {
|
||||||
|
|||||||
12
main.go
12
main.go
@@ -5,9 +5,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
garmin "github.com/sstent/go-garth/pkg/garmin"
|
||||||
"github.com/sstent/go-garth/internal/auth/credentials"
|
credentials "github.com/sstent/go-garth/pkg/garth/auth/credentials"
|
||||||
types "github.com/sstent/go-garth/pkg/garmin"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -18,7 +17,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
garminClient, err := client.NewClient(domain)
|
garminClient, err := garmin.NewClient(domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create client: %v", err)
|
log.Fatalf("Failed to create client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -41,7 +40,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test getting activities
|
// Test getting activities
|
||||||
activities, err := garminClient.GetActivities(5)
|
opts := garmin.ActivityOptions{Limit: 5}
|
||||||
|
activities, err := garminClient.ListActivities(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to get activities: %v", err)
|
log.Fatalf("Failed to get activities: %v", err)
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func main() {
|
|||||||
displayActivities(activities)
|
displayActivities(activities)
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayActivities(activities []types.Activity) {
|
func displayActivities(activities []garmin.Activity) {
|
||||||
fmt.Printf("\n=== Recent Activities ===\n")
|
fmt.Printf("\n=== Recent Activities ===\n")
|
||||||
for i, activity := range activities {
|
for i, activity := range activities {
|
||||||
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
|
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
package oauth
|
package oauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/sstent/go-garth/internal/auth/oauth"
|
|
||||||
"github.com/sstent/go-garth/internal/models/types"
|
|
||||||
"github.com/sstent/go-garth/pkg/garmin"
|
"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
|
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||||
func GetOAuth1Token(domain, ticket string) (*garmin.OAuth1Token, error) {
|
func GetOAuth1Token(domain, ticket string) (*garmin.OAuth1Token, error) {
|
||||||
token, err := oauth.GetOAuth1Token(domain, ticket)
|
token, err := garthoauth.GetOAuth1Token(domain, ticket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -17,7 +16,7 @@ func GetOAuth1Token(domain, ticket string) (*garmin.OAuth1Token, error) {
|
|||||||
|
|
||||||
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
||||||
func ExchangeToken(oauth1Token *garmin.OAuth1Token) (*garmin.OAuth2Token, error) {
|
func ExchangeToken(oauth1Token *garmin.OAuth1Token) (*garmin.OAuth2Token, error) {
|
||||||
token, err := oauth.ExchangeToken((*types.OAuth1Token)(oauth1Token))
|
token, err := garthoauth.ExchangeToken(oauth1Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package garmin_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"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/data"
|
||||||
"github.com/sstent/go-garth/internal/testutils"
|
"github.com/sstent/go-garth/internal/testutils"
|
||||||
"testing"
|
"testing"
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"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"
|
"github.com/sstent/go-garth/internal/errors"
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
models "github.com/sstent/go-garth/shared/models"
|
models "github.com/sstent/go-garth/shared/models"
|
||||||
)
|
)
|
||||||
@@ -51,12 +50,12 @@ func (c *Client) GetUserSettings() (*models.UserSettings, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserProfile implements the APIClient interface
|
// GetUserProfile implements the APIClient interface
|
||||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
func (c *Client) GetUserProfile() (*UserProfile, error) {
|
||||||
return c.Client.GetUserProfile()
|
return c.Client.GetUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWellnessData implements the APIClient interface
|
// 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)
|
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
|
// 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)
|
return c.Client.GetDetailedSleepData(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHrvData retrieves HRV data for a specified number of days
|
// 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)
|
return c.Client.GetDailyHRVData(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStressData retrieves stress data
|
// 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)
|
return c.Client.GetStressData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBodyBatteryData retrieves Body Battery data
|
// 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)
|
return c.Client.GetDetailedBodyBatteryData(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStepsData retrieves steps data for a specified date range
|
// 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)
|
return c.Client.GetStepsData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDistanceData retrieves distance data for a specified date range
|
// 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)
|
return c.Client.GetDistanceData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCaloriesData retrieves calories data for a specified date range
|
// 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)
|
return c.Client.GetCaloriesData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVO2MaxData retrieves VO2 max data for a specified date range
|
// 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)
|
return c.Client.GetVO2MaxData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHeartRateZones retrieves heart rate zone data
|
// GetHeartRateZones retrieves heart rate zone data
|
||||||
func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
func (c *Client) GetHeartRateZones() (*HeartRateZones, error) {
|
||||||
return c.Client.GetHeartRateZones()
|
return c.Client.GetHeartRateZones()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrainingStatus retrieves current training status
|
// 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)
|
return c.Client.GetTrainingStatus(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTrainingLoad retrieves training load data
|
// 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)
|
return c.Client.GetTrainingLoad(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFitnessAge retrieves fitness age calculation
|
// GetFitnessAge retrieves fitness age calculation
|
||||||
func (c *Client) GetFitnessAge() (*types.FitnessAge, error) {
|
func (c *Client) GetFitnessAge() (*FitnessAge, error) {
|
||||||
// TODO: Implement GetFitnessAge in internalClient.Client
|
// TODO: Implement GetFitnessAge in internalClient.Client
|
||||||
return nil, fmt.Errorf("GetFitnessAge not implemented in internalClient.Client")
|
return nil, fmt.Errorf("GetFitnessAge not implemented in internalClient.Client")
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth1Token returns the OAuth1 token
|
// OAuth1Token returns the OAuth1 token
|
||||||
func (c *Client) OAuth1Token() *types.OAuth1Token {
|
func (c *Client) OAuth1Token() *OAuth1Token {
|
||||||
return c.Client.OAuth1Token
|
return c.Client.OAuth1Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2Token returns the OAuth2 token
|
// OAuth2Token returns the OAuth2 token
|
||||||
func (c *Client) OAuth2Token() *types.OAuth2Token {
|
func (c *Client) OAuth2Token() *OAuth2Token {
|
||||||
return c.Client.OAuth2Token
|
return c.Client.OAuth2Token
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"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"
|
"github.com/sstent/go-garth/internal/models/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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/data"
|
||||||
"github.com/sstent/go-garth/internal/stats"
|
"github.com/sstent/go-garth/internal/stats"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package garmin
|
package garmin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/stats"
|
"github.com/sstent/go-garth/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,21 +36,3 @@ func NewDailySleep() Stats {
|
|||||||
func NewDailyHRV() Stats {
|
func NewDailyHRV() Stats {
|
||||||
return stats.NewDailyHRV()
|
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"`
|
|
||||||
}
|
|
||||||
@@ -1,90 +1,99 @@
|
|||||||
package garmin
|
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
|
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||||
type GarminTime = types.GarminTime
|
type GarminTime = garth.GarminTime
|
||||||
|
|
||||||
// SessionData represents saved session information
|
// SessionData represents saved session information
|
||||||
type SessionData = types.SessionData
|
type SessionData = garth.SessionData
|
||||||
|
|
||||||
// ActivityType represents the type of activity
|
// ActivityType represents the type of activity
|
||||||
type ActivityType = types.ActivityType
|
type ActivityType = garth.ActivityType
|
||||||
|
|
||||||
// EventType represents the event type of an activity
|
// EventType represents the event type of an activity
|
||||||
type EventType = types.EventType
|
type EventType = garth.EventType
|
||||||
|
|
||||||
// Activity represents a Garmin Connect activity
|
// Activity represents a Garmin Connect activity
|
||||||
type Activity = types.Activity
|
type Activity = garth.Activity
|
||||||
|
|
||||||
// UserProfile represents a Garmin user profile
|
// UserProfile represents a Garmin user profile
|
||||||
type UserProfile = types.UserProfile
|
type UserProfile = garth.UserProfile
|
||||||
|
|
||||||
// OAuth1Token represents OAuth1 token response
|
// OAuth1Token represents OAuth1 token response
|
||||||
type OAuth1Token = types.OAuth1Token
|
type OAuth1Token = garth.OAuth1Token
|
||||||
|
|
||||||
// OAuth2Token represents OAuth2 token response
|
// OAuth2Token represents OAuth2 token response
|
||||||
type OAuth2Token = types.OAuth2Token
|
type OAuth2Token = garth.OAuth2Token
|
||||||
|
|
||||||
// DetailedSleepData represents comprehensive sleep data
|
// DetailedSleepData represents comprehensive sleep data
|
||||||
type DetailedSleepData = types.DetailedSleepData
|
type DetailedSleepData = garth.DetailedSleepData
|
||||||
|
|
||||||
// SleepLevel represents different sleep stages
|
// SleepLevel represents different sleep stages
|
||||||
type SleepLevel = types.SleepLevel
|
type SleepLevel = garth.SleepLevel
|
||||||
|
|
||||||
// SleepMovement represents movement during sleep
|
// SleepMovement represents movement during sleep
|
||||||
type SleepMovement = types.SleepMovement
|
type SleepMovement = garth.SleepMovement
|
||||||
|
|
||||||
// SleepScore represents detailed sleep scoring
|
// SleepScore represents detailed sleep scoring
|
||||||
type SleepScore = types.SleepScore
|
type SleepScore = garth.SleepScore
|
||||||
|
|
||||||
// SleepScoreBreakdown represents breakdown of sleep score
|
// SleepScoreBreakdown represents breakdown of sleep score
|
||||||
type SleepScoreBreakdown = types.SleepScoreBreakdown
|
type SleepScoreBreakdown = garth.SleepScoreBreakdown
|
||||||
|
|
||||||
// HRVBaseline represents HRV baseline data
|
// HRVBaseline represents HRV baseline data
|
||||||
type HRVBaseline = types.HRVBaseline
|
type HRVBaseline = garth.HRVBaseline
|
||||||
|
|
||||||
// DailyHRVData represents comprehensive daily HRV data
|
// DailyHRVData represents comprehensive daily HRV data
|
||||||
type DailyHRVData = types.DailyHRVData
|
type DailyHRVData = garth.DailyHRVData
|
||||||
|
|
||||||
// BodyBatteryEvent represents events that impact Body Battery
|
// BodyBatteryEvent represents events that impact Body Battery
|
||||||
type BodyBatteryEvent = types.BodyBatteryEvent
|
type BodyBatteryEvent = garth.BodyBatteryEvent
|
||||||
|
|
||||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||||
type DetailedBodyBatteryData = types.DetailedBodyBatteryData
|
type DetailedBodyBatteryData = garth.DetailedBodyBatteryData
|
||||||
|
|
||||||
// TrainingStatus represents current training status
|
// TrainingStatus represents current training status
|
||||||
type TrainingStatus = types.TrainingStatus
|
type TrainingStatus = garth.TrainingStatus
|
||||||
|
|
||||||
// TrainingLoad represents training load data
|
// TrainingLoad represents training load data
|
||||||
type TrainingLoad = types.TrainingLoad
|
type TrainingLoad = garth.TrainingLoad
|
||||||
|
|
||||||
// FitnessAge represents fitness age calculation
|
// FitnessAge represents fitness age calculation
|
||||||
type FitnessAge = types.FitnessAge
|
type FitnessAge = garth.FitnessAge
|
||||||
|
|
||||||
// VO2MaxData represents VO2 max data
|
// VO2MaxData represents VO2 max data
|
||||||
type VO2MaxData = types.VO2MaxData
|
type VO2MaxData = garth.VO2MaxData
|
||||||
|
|
||||||
// VO2MaxEntry represents a single VO2 max entry
|
// VO2MaxEntry represents a single VO2 max entry
|
||||||
type VO2MaxEntry = types.VO2MaxEntry
|
type VO2MaxEntry = garth.VO2MaxEntry
|
||||||
|
|
||||||
// HeartRateZones represents heart rate zone data
|
// HeartRateZones represents heart rate zone data
|
||||||
type HeartRateZones = types.HeartRateZones
|
type HeartRateZones = garth.HeartRateZones
|
||||||
|
|
||||||
// HRZone represents a single heart rate zone
|
// HRZone represents a single heart rate zone
|
||||||
type HRZone = types.HRZone
|
type HRZone = garth.HRZone
|
||||||
|
|
||||||
// WellnessData represents additional wellness metrics
|
// WellnessData represents additional wellness metrics
|
||||||
type WellnessData = types.WellnessData
|
type WellnessData = garth.WellnessData
|
||||||
|
|
||||||
// SleepData represents sleep summary data
|
// SleepData represents sleep summary data
|
||||||
type SleepData = types.SleepData
|
type SleepData = garth.SleepData
|
||||||
|
|
||||||
// HrvData represents Heart Rate Variability data
|
// HrvData represents Heart Rate Variability data
|
||||||
type HrvData = types.HrvData
|
type HrvData = garth.HrvData
|
||||||
|
|
||||||
// StressData represents stress level data
|
// StressData represents stress level data
|
||||||
type StressData = types.StressData
|
type StressData = garth.StressData
|
||||||
|
|
||||||
// BodyBatteryData represents Body Battery data
|
// 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
|
||||||
|
|||||||
37
pkg/garth/auth/credentials/credentials.go
Normal file
37
pkg/garth/auth/credentials/credentials.go
Normal 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
|
||||||
|
}
|
||||||
4
pkg/garth/auth/credentials/doc.go
Normal file
4
pkg/garth/auth/credentials/doc.go
Normal 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
|
||||||
5
pkg/garth/auth/oauth/doc.go
Normal file
5
pkg/garth/auth/oauth/doc.go
Normal 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
|
||||||
162
pkg/garth/auth/oauth/oauth.go
Normal file
162
pkg/garth/auth/oauth/oauth.go
Normal 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
|
||||||
|
}
|
||||||
5
pkg/garth/auth/sso/doc.go
Normal file
5
pkg/garth/auth/sso/doc.go
Normal 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
265
pkg/garth/auth/sso/sso.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
package client_test
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
credentials "github.com/sstent/go-garth/pkg/garth/auth/credentials"
|
||||||
"github.com/sstent/go-garth/internal/auth/credentials"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -20,7 +19,7 @@ func TestClient_Login_Functional(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to load credentials from .env file. Please ensure GARMIN_EMAIL, GARMIN_PASSWORD, and GARMIN_DOMAIN are set.")
|
require.NoError(t, err, "Failed to load credentials from .env file. Please ensure GARMIN_EMAIL, GARMIN_PASSWORD, and GARMIN_DOMAIN are set.")
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
c, err := client.NewClient(domain)
|
c, err := NewClient(domain)
|
||||||
require.NoError(t, err, "Failed to create client")
|
require.NoError(t, err, "Failed to create client")
|
||||||
|
|
||||||
// Perform login
|
// Perform login
|
||||||
@@ -34,4 +33,4 @@ func TestClient_Login_Functional(t *testing.T) {
|
|||||||
// Logout for cleanup
|
// Logout for cleanup
|
||||||
err = c.Logout()
|
err = c.Logout()
|
||||||
assert.NoError(t, err, "Logout failed")
|
assert.NoError(t, err, "Logout failed")
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
"github.com/sstent/go-garth/internal/auth/sso"
|
"github.com/sstent/go-garth/internal/auth/sso"
|
||||||
"github.com/sstent/go-garth/internal/errors"
|
"github.com/sstent/go-garth/internal/errors"
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||||
models "github.com/sstent/go-garth/shared/models"
|
models "github.com/sstent/go-garth/shared/models"
|
||||||
)
|
)
|
||||||
@@ -27,8 +27,8 @@ type Client struct {
|
|||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
Username string
|
Username string
|
||||||
AuthToken string
|
AuthToken string
|
||||||
OAuth1Token *types.OAuth1Token
|
OAuth1Token *garth.OAuth1Token
|
||||||
OAuth2Token *types.OAuth2Token
|
OAuth2Token *garth.OAuth2Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify that Client implements shared.APIClient
|
// Verify that Client implements shared.APIClient
|
||||||
@@ -216,7 +216,7 @@ func (c *Client) Logout() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetUserProfile retrieves the current user's full profile
|
// GetUserProfile retrieves the current user's full profile
|
||||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
func (c *Client) GetUserProfile() (*garth.UserProfile, error) {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -268,7 +268,7 @@ func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var profile types.UserProfile
|
var profile garth.UserProfile
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||||
return nil, &errors.IOError{
|
return nil, &errors.IOError{
|
||||||
GarthError: errors.GarthError{
|
GarthError: errors.GarthError{
|
||||||
@@ -437,7 +437,7 @@ func (c *Client) Download(activityID string, format string, filePath string) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetActivities retrieves recent activities
|
// GetActivities retrieves recent activities
|
||||||
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
func (c *Client) GetActivities(limit int) ([]garth.Activity, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
@@ -490,7 +490,7 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var activities []types.Activity
|
var activities []garth.Activity
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||||
return nil, &errors.IOError{
|
return nil, &errors.IOError{
|
||||||
GarthError: errors.GarthError{
|
GarthError: errors.GarthError{
|
||||||
@@ -503,49 +503,49 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
|||||||
return activities, nil
|
return activities, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) {
|
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]garth.SleepData, error) {
|
||||||
// TODO: Implement GetSleepData
|
// TODO: Implement GetSleepData
|
||||||
return nil, fmt.Errorf("GetSleepData not implemented")
|
return nil, fmt.Errorf("GetSleepData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetHrvData retrieves HRV data for a specified number of days
|
// GetHrvData retrieves HRV data for a specified number of days
|
||||||
func (c *Client) GetHrvData(days int) ([]types.HrvData, error) {
|
func (c *Client) GetHrvData(days int) ([]garth.HrvData, error) {
|
||||||
// TODO: Implement GetHrvData
|
// TODO: Implement GetHrvData
|
||||||
return nil, fmt.Errorf("GetHrvData not implemented")
|
return nil, fmt.Errorf("GetHrvData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStressData retrieves stress data
|
// GetStressData retrieves stress data
|
||||||
func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) {
|
func (c *Client) GetStressData(startDate, endDate time.Time) ([]garth.StressData, error) {
|
||||||
// TODO: Implement GetStressData
|
// TODO: Implement GetStressData
|
||||||
return nil, fmt.Errorf("GetStressData not implemented")
|
return nil, fmt.Errorf("GetStressData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBodyBatteryData retrieves Body Battery data
|
// GetBodyBatteryData retrieves Body Battery data
|
||||||
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) {
|
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]garth.BodyBatteryData, error) {
|
||||||
// TODO: Implement GetBodyBatteryData
|
// TODO: Implement GetBodyBatteryData
|
||||||
return nil, fmt.Errorf("GetBodyBatteryData not implemented")
|
return nil, fmt.Errorf("GetBodyBatteryData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStepsData retrieves steps data for a specified date range
|
// 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) ([]garth.StepsData, error) {
|
||||||
// TODO: Implement GetStepsData
|
// TODO: Implement GetStepsData
|
||||||
return nil, fmt.Errorf("GetStepsData not implemented")
|
return nil, fmt.Errorf("GetStepsData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDistanceData retrieves distance data for a specified date range
|
// 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) ([]garth.DistanceData, error) {
|
||||||
// TODO: Implement GetDistanceData
|
// TODO: Implement GetDistanceData
|
||||||
return nil, fmt.Errorf("GetDistanceData not implemented")
|
return nil, fmt.Errorf("GetDistanceData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCaloriesData retrieves calories data for a specified date range
|
// 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) ([]garth.CaloriesData, error) {
|
||||||
// TODO: Implement GetCaloriesData
|
// TODO: Implement GetCaloriesData
|
||||||
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]garth.VO2MaxData, error) {
|
||||||
// Get user settings which contains current VO2 max values
|
// Get user settings which contains current VO2 max values
|
||||||
settings, err := c.GetUserSettings()
|
settings, err := c.GetUserSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -553,10 +553,10 @@ func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create VO2MaxData for the date range
|
// Create VO2MaxData for the date range
|
||||||
var results []types.VO2MaxData
|
var results []garth.VO2MaxData
|
||||||
current := startDate
|
current := startDate
|
||||||
for !current.After(endDate) {
|
for !current.After(endDate) {
|
||||||
vo2Data := types.VO2MaxData{
|
vo2Data := garth.VO2MaxData{
|
||||||
Date: current,
|
Date: current,
|
||||||
UserProfilePK: settings.ID,
|
UserProfilePK: settings.ID,
|
||||||
}
|
}
|
||||||
@@ -577,20 +577,20 @@ func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
||||||
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
func (c *Client) GetCurrentVO2Max() (*garth.VO2MaxProfile, error) {
|
||||||
settings, err := c.GetUserSettings()
|
settings, err := c.GetUserSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
profile := &types.VO2MaxProfile{
|
profile := &garth.VO2MaxProfile{
|
||||||
UserProfilePK: settings.ID,
|
UserProfilePK: settings.ID,
|
||||||
LastUpdated: time.Now(),
|
LastUpdated: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add running VO2 max if available
|
// Add running VO2 max if available
|
||||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||||
profile.Running = &types.VO2MaxEntry{
|
profile.Running = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxRunning,
|
Value: *settings.UserData.VO2MaxRunning,
|
||||||
ActivityType: "running",
|
ActivityType: "running",
|
||||||
Date: time.Now(),
|
Date: time.Now(),
|
||||||
@@ -600,7 +600,7 @@ func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
|||||||
|
|
||||||
// Add cycling VO2 max if available
|
// Add cycling VO2 max if available
|
||||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||||
profile.Cycling = &types.VO2MaxEntry{
|
profile.Cycling = &garth.VO2MaxEntry{
|
||||||
Value: *settings.UserData.VO2MaxCycling,
|
Value: *settings.UserData.VO2MaxCycling,
|
||||||
ActivityType: "cycling",
|
ActivityType: "cycling",
|
||||||
Date: time.Now(),
|
Date: time.Now(),
|
||||||
@@ -612,7 +612,7 @@ func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetHeartRateZones retrieves heart rate zone data
|
// GetHeartRateZones retrieves heart rate zone data
|
||||||
func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
func (c *Client) GetHeartRateZones() (*garth.HeartRateZones, error) {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -661,7 +661,7 @@ func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hrZones types.HeartRateZones
|
var hrZones garth.HeartRateZones
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil {
|
||||||
return nil, &errors.IOError{
|
return nil, &errors.IOError{
|
||||||
GarthError: errors.GarthError{
|
GarthError: errors.GarthError{
|
||||||
@@ -675,7 +675,7 @@ func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetWellnessData retrieves comprehensive wellness data for a specified date range
|
// GetWellnessData retrieves comprehensive wellness data for a specified date range
|
||||||
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) {
|
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]garth.WellnessData, error) {
|
||||||
scheme := "https"
|
scheme := "https"
|
||||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||||
scheme = "http"
|
scheme = "http"
|
||||||
@@ -728,7 +728,7 @@ func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.Wellness
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var wellnessData []types.WellnessData
|
var wellnessData []garth.WellnessData
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil {
|
||||||
return nil, &errors.IOError{
|
return nil, &errors.IOError{
|
||||||
GarthError: errors.GarthError{
|
GarthError: errors.GarthError{
|
||||||
@@ -743,7 +743,7 @@ func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.Wellness
|
|||||||
|
|
||||||
// SaveSession saves the current session to a file
|
// SaveSession saves the current session to a file
|
||||||
func (c *Client) SaveSession(filename string) error {
|
func (c *Client) SaveSession(filename string) error {
|
||||||
session := types.SessionData{
|
session := garth.SessionData{
|
||||||
Domain: c.Domain,
|
Domain: c.Domain,
|
||||||
Username: c.Username,
|
Username: c.Username,
|
||||||
AuthToken: c.AuthToken,
|
AuthToken: c.AuthToken,
|
||||||
@@ -772,7 +772,7 @@ func (c *Client) SaveSession(filename string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
func (c *Client) GetDetailedSleepData(date time.Time) (*garth.DetailedSleepData, error) {
|
||||||
dateStr := date.Format("2006-01-02")
|
dateStr := date.Format("2006-01-02")
|
||||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||||
c.Username, dateStr)
|
c.Username, dateStr)
|
||||||
@@ -787,10 +787,10 @@ func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
DailySleepDTO *garth.DetailedSleepData `json:"dailySleepDTO"`
|
||||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
SleepMovement []garth.SleepMovement `json:"sleepMovement"`
|
||||||
RemSleepData bool `json:"remSleepData"`
|
RemSleepData bool `json:"remSleepData"`
|
||||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
SleepLevels []garth.SleepLevel `json:"sleepLevels"`
|
||||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||||
@@ -815,7 +815,7 @@ func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
||||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
func (c *Client) GetDailyHRVData(date time.Time) (*garth.DailyHRVData, error) {
|
||||||
dateStr := date.Format("2006-01-02")
|
dateStr := date.Format("2006-01-02")
|
||||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||||
c.Username, dateStr)
|
c.Username, dateStr)
|
||||||
@@ -830,8 +830,8 @@ func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response struct {
|
var response struct {
|
||||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
HRVSummary garth.DailyHRVData `json:"hrvSummary"`
|
||||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
HRVReadings []garth.HRVReading `json:"hrvReadings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &response); err != nil {
|
if err := json.Unmarshal(data, &response); err != nil {
|
||||||
@@ -844,7 +844,7 @@ func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
||||||
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*garth.DetailedBodyBatteryData, error) {
|
||||||
dateStr := date.Format("2006-01-02")
|
dateStr := date.Format("2006-01-02")
|
||||||
|
|
||||||
// Get main Body Battery data
|
// Get main Body Battery data
|
||||||
@@ -862,14 +862,14 @@ func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBody
|
|||||||
data2 = []byte("[]")
|
data2 = []byte("[]")
|
||||||
}
|
}
|
||||||
|
|
||||||
var result types.DetailedBodyBatteryData
|
var result garth.DetailedBodyBatteryData
|
||||||
if len(data1) > 0 {
|
if len(data1) > 0 {
|
||||||
if err := json.Unmarshal(data1, &result); err != nil {
|
if err := json.Unmarshal(data1, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var events []types.BodyBatteryEvent
|
var events []garth.BodyBatteryEvent
|
||||||
if len(data2) > 0 {
|
if len(data2) > 0 {
|
||||||
if err := json.Unmarshal(data2, &events); err == nil {
|
if err := json.Unmarshal(data2, &events); err == nil {
|
||||||
result.Events = events
|
result.Events = events
|
||||||
@@ -880,7 +880,7 @@ func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBody
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTrainingStatus retrieves current training status
|
// GetTrainingStatus retrieves current training status
|
||||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
func (c *Client) GetTrainingStatus(date time.Time) (*garth.TrainingStatus, error) {
|
||||||
dateStr := date.Format("2006-01-02")
|
dateStr := date.Format("2006-01-02")
|
||||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||||
|
|
||||||
@@ -893,7 +893,7 @@ func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var result types.TrainingStatus
|
var result garth.TrainingStatus
|
||||||
if err := json.Unmarshal(data, &result); err != nil {
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||||
}
|
}
|
||||||
@@ -902,7 +902,7 @@ func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTrainingLoad retrieves training load data
|
// GetTrainingLoad retrieves training load data
|
||||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
func (c *Client) GetTrainingLoad(date time.Time) (*garth.TrainingLoad, error) {
|
||||||
dateStr := date.Format("2006-01-02")
|
dateStr := date.Format("2006-01-02")
|
||||||
endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
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)
|
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||||
@@ -916,7 +916,7 @@ func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var results []types.TrainingLoad
|
var results []garth.TrainingLoad
|
||||||
if err := json.Unmarshal(data, &results); err != nil {
|
if err := json.Unmarshal(data, &results); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||||
}
|
}
|
||||||
@@ -940,7 +940,7 @@ func (c *Client) LoadSession(filename string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var session types.SessionData
|
var session garth.SessionData
|
||||||
if err := json.Unmarshal(data, &session); err != nil {
|
if err := json.Unmarshal(data, &session); err != nil {
|
||||||
return &errors.IOError{
|
return &errors.IOError{
|
||||||
GarthError: errors.GarthError{
|
GarthError: errors.GarthError{
|
||||||
@@ -8,11 +8,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/testutils"
|
"github.com/sstent/go-garth/internal/testutils"
|
||||||
|
"github.com/sstent/go-garth/pkg/garth/client"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/sstent/go-garth/internal/api/client"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_GetUserProfile(t *testing.T) {
|
func TestClient_GetUserProfile(t *testing.T) {
|
||||||
@@ -46,4 +45,4 @@ func TestClient_GetUserProfile(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "testuser", profile.UserName)
|
assert.Equal(t, "testuser", profile.UserName)
|
||||||
assert.Equal(t, "Test User", profile.DisplayName)
|
assert.Equal(t, "Test User", profile.DisplayName)
|
||||||
}
|
}
|
||||||
47
pkg/garth/types/auth.go
Normal file
47
pkg/garth/types/auth.go
Normal 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
5
pkg/garth/types/doc.go
Normal 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
424
pkg/garth/types/garmin.go
Normal 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"`
|
||||||
|
}
|
||||||
82
pkg/garth/types/garmin_test.go
Normal file
82
pkg/garth/types/garmin_test.go
Normal 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), >)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
types "github.com/sstent/go-garth/internal/models/types"
|
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||||
"github.com/sstent/go-garth/shared/models"
|
"github.com/sstent/go-garth/shared/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +14,6 @@ type APIClient interface {
|
|||||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||||
GetUsername() string
|
GetUsername() string
|
||||||
GetUserSettings() (*models.UserSettings, error)
|
GetUserSettings() (*models.UserSettings, error)
|
||||||
GetUserProfile() (*types.UserProfile, error)
|
GetUserProfile() (*garth.UserProfile, error)
|
||||||
GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error)
|
GetWellnessData(startDate, endDate time.Time) ([]garth.WellnessData, error)
|
||||||
}
|
}
|
||||||
|
|||||||
21
todo.md
Normal file
21
todo.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Project Plan: Exposing Internal Packages
|
||||||
|
|
||||||
|
This document outlines the plan for refactoring the project to expose internal packages as public APIs.
|
||||||
|
|
||||||
|
## I. Refactor the Core Client
|
||||||
|
|
||||||
|
- [ ] Move the `Client` struct and its methods from `internal/api/client` to `pkg/garth/client`.
|
||||||
|
- [ ] Update all internal import paths to reflect the new location of the client.
|
||||||
|
- [ ] Review the public API of the client and ensure that only essential functions are exported.
|
||||||
|
|
||||||
|
## II. Expose Data Models
|
||||||
|
|
||||||
|
- [ ] Move all data structures from `internal/models/types` to `pkg/garth/types`.
|
||||||
|
- [ ] Update all internal import paths to reflect the new location of the data types.
|
||||||
|
- [ ] Review the naming of the data types and ensure that they are consistent and clear.
|
||||||
|
|
||||||
|
## III. Make Authentication Logic Public
|
||||||
|
|
||||||
|
- [ ] Move the authentication logic from `internal/auth` to `pkg/garth/auth`.
|
||||||
|
- [ ] Update all internal import paths to reflect the new location of the authentication logic.
|
||||||
|
- [ ] Create a clean and well-documented public API for the authentication functions.
|
||||||
Reference in New Issue
Block a user