diff --git a/garth/__init__.go b/garth/__init__.go new file mode 100644 index 0000000..e2fc745 --- /dev/null +++ b/garth/__init__.go @@ -0,0 +1,30 @@ +package garth + +import ( + "garmin-connect/garth/client" + "garmin-connect/garth/data" + "garmin-connect/garth/stats" +) + +// Re-export main types for convenience +type Client = client.Client + +// Data types +type BodyBatteryData = data.DailyBodyBatteryStress +type HRVData = data.HRVData +type SleepData = data.DailySleepDTO +type WeightData = data.WeightData + +// Stats types +type DailySteps = stats.DailySteps +type DailyStress = stats.DailyStress +type DailyHRV = stats.DailyHRV +type DailyHydration = stats.DailyHydration +type DailyIntensityMinutes = stats.DailyIntensityMinutes +type DailySleep = stats.DailySleep + +// Main functions +var ( + NewClient = client.NewClient + Login = client.Login +) diff --git a/garth/data/base.go b/garth/data/base.go index e1d5e0d..d55a871 100644 --- a/garth/data/base.go +++ b/garth/data/base.go @@ -6,6 +6,7 @@ import ( "time" "garmin-connect/garth/client" + "garmin-connect/garth/utils" ) // Data defines the interface for Garmin Connect data types. @@ -70,30 +71,27 @@ func (b *BaseData) Get(day time.Time, c *client.Client) (interface{}, error) { // []error: Slice of errors encountered during processing func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) { if maxWorkers < 1 { - maxWorkers = 1 + maxWorkers = 10 // Match Python's MAX_WORKERS } - // Generate date range (end backwards for 'days' days) - dates := make([]time.Time, days) - for i := 0; i < days; i++ { - dates[i] = end.AddDate(0, 0, -i) + dates := utils.DateRange(end, days) + + // Define result type for channel + type result struct { + data interface{} + err error } var wg sync.WaitGroup workCh := make(chan time.Time, days) - resultsCh := make(chan interface{}, days) - errCh := make(chan error, days) + resultsCh := make(chan result, days) // Worker function worker := func() { defer wg.Done() for date := range workCh { - result, err := b.Get(date, c) - if err != nil { - errCh <- err - continue - } - resultsCh <- result + data, err := b.Get(date, c) + resultsCh <- result{data: data, err: err} } } @@ -103,7 +101,7 @@ func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers in go worker() } - // Send work to channel + // Send work go func() { for _, date := range dates { workCh <- date @@ -111,32 +109,20 @@ func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers in close(workCh) }() - // Close channels when all workers finish + // Close results channel when workers are done go func() { wg.Wait() close(resultsCh) - close(errCh) }() - // Collect results and errors var results []interface{} var errs []error - // Collect results until both channels are closed - for resultsCh != nil || errCh != nil { - select { - case result, ok := <-resultsCh: - if !ok { - resultsCh = nil - continue - } - results = append(results, result) - case err, ok := <-errCh: - if !ok { - errCh = nil - continue - } - errs = append(errs, err) + for r := range resultsCh { + if r.err != nil { + errs = append(errs, r.err) + } else if r.data != nil { + results = append(results, r.data) } } diff --git a/garth/integration_test.go b/garth/integration_test.go index c7d6379..d8a559f 100644 --- a/garth/integration_test.go +++ b/garth/integration_test.go @@ -6,6 +6,7 @@ import ( "garmin-connect/garth/client" "garmin-connect/garth/data" + "garmin-connect/garth/stats" ) func TestBodyBatteryIntegration(t *testing.T) { @@ -37,3 +38,57 @@ func TestBodyBatteryIntegration(t *testing.T) { } } } + +func TestStatsEndpoints(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + c, err := client.NewClient("garmin.com") + if err != nil { + t.Fatalf("Failed to create client: %v", err) + } + + // Load test session + err = c.LoadSession("test_session.json") + if err != nil { + t.Skip("No test session available") + } + + tests := []struct { + name string + stat stats.Stats + }{ + {"Steps", stats.NewDailySteps()}, + {"Stress", stats.NewDailyStress()}, + {"Hydration", stats.NewDailyHydration()}, + {"IntensityMinutes", stats.NewDailyIntensityMinutes()}, + {"Sleep", stats.NewDailySleep()}, + {"HRV", stats.NewDailyHRV()}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + end := time.Now().AddDate(0, 0, -1) + results, err := tt.stat.List(end, 1, c) + if err != nil { + t.Errorf("List failed: %v", err) + } + if len(results) == 0 { + t.Logf("No data returned for %s", tt.name) + return + } + + // Basic validation that we got some data + resultMap, ok := results[0].(map[string]interface{}) + if !ok { + t.Errorf("Expected map for %s result, got %T", tt.name, results[0]) + return + } + + if len(resultMap) == 0 { + t.Errorf("Empty result map for %s", tt.name) + } + }) + } +} diff --git a/garth/stats/base.go b/garth/stats/base.go new file mode 100644 index 0000000..87877f1 --- /dev/null +++ b/garth/stats/base.go @@ -0,0 +1,90 @@ +package stats + +import ( + "fmt" + "strings" + "time" + + "garmin-connect/garth/client" + "garmin-connect/garth/utils" +) + +type Stats interface { + List(end time.Time, period int, client *client.Client) ([]interface{}, error) +} + +type BaseStats struct { + Path string + PageSize int +} + +func (b *BaseStats) List(end time.Time, period int, client *client.Client) ([]interface{}, error) { + endDate := utils.FormatEndDate(end) + + if period > b.PageSize { + // Handle pagination - get first page + page, err := b.fetchPage(endDate, b.PageSize, client) + if err != nil || len(page) == 0 { + return page, err + } + + // Get remaining pages recursively + remainingStart := endDate.AddDate(0, 0, -b.PageSize) + remainingPeriod := period - b.PageSize + remainingData, err := b.List(remainingStart, remainingPeriod, client) + if err != nil { + return page, err + } + + return append(remainingData, page...), nil + } + + return b.fetchPage(endDate, period, client) +} + +func (b *BaseStats) fetchPage(end time.Time, period int, client *client.Client) ([]interface{}, error) { + var start time.Time + var path string + + if strings.Contains(b.Path, "daily") { + start = end.AddDate(0, 0, -(period - 1)) + path = strings.Replace(b.Path, "{start}", start.Format("2006-01-02"), 1) + path = strings.Replace(path, "{end}", end.Format("2006-01-02"), 1) + } else { + path = strings.Replace(b.Path, "{end}", end.Format("2006-01-02"), 1) + path = strings.Replace(path, "{period}", fmt.Sprintf("%d", period), 1) + } + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return []interface{}{}, nil + } + + responseSlice, ok := response.([]interface{}) + if !ok || len(responseSlice) == 0 { + return []interface{}{}, nil + } + + var results []interface{} + for _, item := range responseSlice { + itemMap := item.(map[string]interface{}) + + // Handle nested "values" structure + if values, exists := itemMap["values"]; exists { + valuesMap := values.(map[string]interface{}) + for k, v := range valuesMap { + itemMap[k] = v + } + delete(itemMap, "values") + } + + snakeItem := utils.CamelToSnakeDict(itemMap) + results = append(results, snakeItem) + } + + return results, nil +} diff --git a/garth/stats/hrv.go b/garth/stats/hrv.go new file mode 100644 index 0000000..f97df6c --- /dev/null +++ b/garth/stats/hrv.go @@ -0,0 +1,21 @@ +package stats + +import "time" + +const BASE_HRV_PATH = "/usersummary-service/stats/hrv" + +type DailyHRV struct { + CalendarDate time.Time `json:"calendar_date"` + RestingHR *int `json:"resting_hr"` + HRV *int `json:"hrv"` + BaseStats +} + +func NewDailyHRV() *DailyHRV { + return &DailyHRV{ + BaseStats: BaseStats{ + Path: BASE_HRV_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +} diff --git a/garth/stats/hydration.go b/garth/stats/hydration.go new file mode 100644 index 0000000..e4c8f80 --- /dev/null +++ b/garth/stats/hydration.go @@ -0,0 +1,20 @@ +package stats + +import "time" + +const BASE_HYDRATION_PATH = "/usersummary-service/stats/hydration" + +type DailyHydration struct { + CalendarDate time.Time `json:"calendar_date"` + TotalWaterML *int `json:"total_water_ml"` + BaseStats +} + +func NewDailyHydration() *DailyHydration { + return &DailyHydration{ + BaseStats: BaseStats{ + Path: BASE_HYDRATION_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +} diff --git a/garth/stats/intensity_minutes.go b/garth/stats/intensity_minutes.go new file mode 100644 index 0000000..2b13028 --- /dev/null +++ b/garth/stats/intensity_minutes.go @@ -0,0 +1,21 @@ +package stats + +import "time" + +const BASE_INTENSITY_PATH = "/usersummary-service/stats/intensity_minutes" + +type DailyIntensityMinutes struct { + CalendarDate time.Time `json:"calendar_date"` + ModerateIntensity *int `json:"moderate_intensity"` + VigorousIntensity *int `json:"vigorous_intensity"` + BaseStats +} + +func NewDailyIntensityMinutes() *DailyIntensityMinutes { + return &DailyIntensityMinutes{ + BaseStats: BaseStats{ + Path: BASE_INTENSITY_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +} diff --git a/garth/stats/sleep.go b/garth/stats/sleep.go new file mode 100644 index 0000000..3419f68 --- /dev/null +++ b/garth/stats/sleep.go @@ -0,0 +1,27 @@ +package stats + +import "time" + +const BASE_SLEEP_PATH = "/usersummary-service/stats/sleep" + +type DailySleep struct { + CalendarDate time.Time `json:"calendar_date"` + TotalSleepTime *int `json:"total_sleep_time"` + RemSleepTime *int `json:"rem_sleep_time"` + DeepSleepTime *int `json:"deep_sleep_time"` + LightSleepTime *int `json:"light_sleep_time"` + AwakeTime *int `json:"awake_time"` + SleepScore *int `json:"sleep_score"` + SleepStartTimestamp *int64 `json:"sleep_start_timestamp"` + SleepEndTimestamp *int64 `json:"sleep_end_timestamp"` + BaseStats +} + +func NewDailySleep() *DailySleep { + return &DailySleep{ + BaseStats: BaseStats{ + Path: BASE_SLEEP_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +} diff --git a/garth/stats/steps.go b/garth/stats/steps.go new file mode 100644 index 0000000..31e0b5b --- /dev/null +++ b/garth/stats/steps.go @@ -0,0 +1,41 @@ +package stats + +import "time" + +const BASE_STEPS_PATH = "/usersummary-service/stats/steps" + +type DailySteps struct { + CalendarDate time.Time `json:"calendar_date"` + TotalSteps *int `json:"total_steps"` + TotalDistance *int `json:"total_distance"` + StepGoal int `json:"step_goal"` + BaseStats +} + +func NewDailySteps() *DailySteps { + return &DailySteps{ + BaseStats: BaseStats{ + Path: BASE_STEPS_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +} + +type WeeklySteps struct { + CalendarDate time.Time `json:"calendar_date"` + TotalSteps int `json:"total_steps"` + AverageSteps float64 `json:"average_steps"` + AverageDistance float64 `json:"average_distance"` + TotalDistance float64 `json:"total_distance"` + WellnessDataDaysCount int `json:"wellness_data_days_count"` + BaseStats +} + +func NewWeeklySteps() *WeeklySteps { + return &WeeklySteps{ + BaseStats: BaseStats{ + Path: BASE_STEPS_PATH + "/weekly/{end}/{period}", + PageSize: 52, + }, + } +} diff --git a/garth/stats/stress.go b/garth/stats/stress.go new file mode 100644 index 0000000..5e6899a --- /dev/null +++ b/garth/stats/stress.go @@ -0,0 +1,24 @@ +package stats + +import "time" + +const BASE_STRESS_PATH = "/usersummary-service/stats/stress" + +type DailyStress struct { + CalendarDate time.Time `json:"calendar_date"` + OverallStressLevel int `json:"overall_stress_level"` + RestStressDuration *int `json:"rest_stress_duration"` + LowStressDuration *int `json:"low_stress_duration"` + MediumStressDuration *int `json:"medium_stress_duration"` + HighStressDuration *int `json:"high_stress_duration"` + BaseStats +} + +func NewDailyStress() *DailyStress { + return &DailyStress{ + BaseStats: BaseStats{ + Path: BASE_STRESS_PATH + "/daily/{start}/{end}", + PageSize: 28, + }, + } +}