porting - part2 wk2 done

This commit is contained in:
2025-09-07 19:01:42 -07:00
parent 5c44f01bc3
commit 84c5c2ba6a
10 changed files with 347 additions and 32 deletions

30
garth/__init__.go Normal file
View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -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)
}
})
}
}

90
garth/stats/base.go Normal file
View File

@@ -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
}

21
garth/stats/hrv.go Normal file
View File

@@ -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,
},
}
}

20
garth/stats/hydration.go Normal file
View File

@@ -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,
},
}
}

View File

@@ -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,
},
}
}

27
garth/stats/sleep.go Normal file
View File

@@ -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,
},
}
}

41
garth/stats/steps.go Normal file
View File

@@ -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,
},
}
}

24
garth/stats/stress.go Normal file
View File

@@ -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,
},
}
}