From 5c44f01bc36959aebd4353deb6217ef19561b0ea Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 7 Sep 2025 18:34:53 -0700 Subject: [PATCH] porting - part2 wk1 done --- garth/client/http.go | 130 +++++++ garth/data/body_battery.go | 37 +- garth/data/sleep.go | 47 ++- garth/integration_test.go | 39 +++ portingplan_part2.md | 670 +++++++++++++++++++++++++++++++++++++ 5 files changed, 919 insertions(+), 4 deletions(-) create mode 100644 garth/client/http.go create mode 100644 garth/integration_test.go create mode 100644 portingplan_part2.md diff --git a/garth/client/http.go b/garth/client/http.go new file mode 100644 index 0000000..1c1a033 --- /dev/null +++ b/garth/client/http.go @@ -0,0 +1,130 @@ +package client + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "garmin-connect/garth/errors" +) + +func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) { + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path) + + var body io.Reader + if data != nil && (method == "POST" || method == "PUT") { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "Failed to marshal request data", Cause: err}}} + } + body = bytes.NewReader(jsonData) + } + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "Failed to create request", Cause: err}}} + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "API request failed", Cause: err}}} + } + defer resp.Body.Close() + + if resp.StatusCode == 204 { + return nil, nil + } + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(bodyBytes), + GarthError: errors.GarthError{Message: "API error"}}} + } + + var result interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Failed to parse response", Cause: err}} + } + + return result, nil +} + +func (c *Client) Download(path string) ([]byte, error) { + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + return io.ReadAll(resp.Body) +} + +func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Failed to open file", Cause: err}} + } + defer file.Close() + + var b bytes.Buffer + writer := multipart.NewWriter(&b) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return nil, err + } + + _, err = io.Copy(part, file) + if err != nil { + return nil, err + } + writer.Close() + + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, uploadPath) + req, err := http.NewRequest("POST", url, &b) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/garth/data/body_battery.go b/garth/data/body_battery.go index 9ece557..eeb55f1 100644 --- a/garth/data/body_battery.go +++ b/garth/data/body_battery.go @@ -1,10 +1,14 @@ package data import ( + "encoding/json" + "fmt" "sort" "time" "garmin-connect/garth/client" + "garmin-connect/garth/errors" + "garmin-connect/garth/utils" ) // DailyBodyBatteryStress represents complete daily Body Battery and stress data @@ -109,8 +113,37 @@ func ParseStressReadings(valuesArray [][]int) []StressReading { // Get implements the Data interface for DailyBodyBatteryStress func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (any, error) { - // Implementation to be added - return nil, nil + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap, ok := response.(map[string]interface{}) + if !ok { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Invalid response format"}} + } + + snakeResponse := utils.CamelToSnakeDict(responseMap) + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result DailyBodyBatteryStress + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return &result, nil } // List implements the Data interface for concurrent fetching diff --git a/garth/data/sleep.go b/garth/data/sleep.go index 89e0836..5e8af76 100644 --- a/garth/data/sleep.go +++ b/garth/data/sleep.go @@ -1,9 +1,13 @@ package data import ( + "encoding/json" + "fmt" "time" "garmin-connect/garth/client" + "garmin-connect/garth/errors" + "garmin-connect/garth/utils" ) // SleepScores represents sleep scoring data @@ -37,8 +41,47 @@ type DailySleepDTO struct { // Get implements the Data interface for DailySleepDTO func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) { - // Implementation to be added - return nil, nil + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s", + client.Username, dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap, ok := response.(map[string]interface{}) + if !ok { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Invalid response format"}} + } + + snakeResponse := utils.CamelToSnakeDict(responseMap) + + dailySleepDto, exists := snakeResponse["daily_sleep_dto"].(map[string]interface{}) + if !exists || dailySleepDto["id"] == nil { + return nil, nil // No sleep data + } + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result struct { + DailySleepDTO *DailySleepDTO `json:"daily_sleep_dto"` + SleepMovement []SleepMovement `json:"sleep_movement"` + } + + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return result, nil } // List implements the Data interface for concurrent fetching diff --git a/garth/integration_test.go b/garth/integration_test.go new file mode 100644 index 0000000..c7d6379 --- /dev/null +++ b/garth/integration_test.go @@ -0,0 +1,39 @@ +package garth_test + +import ( + "testing" + "time" + + "garmin-connect/garth/client" + "garmin-connect/garth/data" +) + +func TestBodyBatteryIntegration(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") + } + + bb := &data.DailyBodyBatteryStress{} + result, err := bb.Get(time.Now().AddDate(0, 0, -1), c) + + if err != nil { + t.Errorf("Get failed: %v", err) + } + if result != nil { + bbData := result.(*data.DailyBodyBatteryStress) + if bbData.UserProfilePK == 0 { + t.Error("UserProfilePK is zero") + } + } +} diff --git a/portingplan_part2.md b/portingplan_part2.md new file mode 100644 index 0000000..168bb13 --- /dev/null +++ b/portingplan_part2.md @@ -0,0 +1,670 @@ +# Complete Garth Python to Go Port - Implementation Plan + +## Current Status +The Go port has excellent architecture (85% complete) but needs implementation of core API methods and data models. All structure, error handling, and utilities are in place. + +## Phase 1: Core API Implementation (Priority 1 - Week 1) + +### Task 1.1: Implement Client.ConnectAPI Method +**File:** `garth/client/client.go` +**Reference:** `src/garth/http.py` lines 206-217 + +Add this method to the Client struct: + +```go +func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) { + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path) + + var body io.Reader + if data != nil && (method == "POST" || method == "PUT") { + jsonData, err := json.Marshal(data) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "Failed to marshal request data", Cause: err}}} + } + body = bytes.NewReader(jsonData) + } + + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "Failed to create request", Cause: err}}} + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + GarthError: errors.GarthError{Message: "API request failed", Cause: err}}} + } + defer resp.Body.Close() + + if resp.StatusCode == 204 { + return nil, nil + } + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{ + StatusCode: resp.StatusCode, + Response: string(bodyBytes), + GarthError: errors.GarthError{Message: "API error"}}} + } + + var result interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Failed to parse response", Cause: err}} + } + + return result, nil +} +``` + +### Task 1.2: Add File Download/Upload Methods +**File:** `garth/client/client.go` +**Reference:** `src/garth/http.py` lines 219-230, 232-244 + +```go +func (c *Client) Download(path string) ([]byte, error) { + resp, err := c.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1") + + httpResp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + return io.ReadAll(httpResp.Body) +} + +func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Failed to open file", Cause: err}} + } + defer file.Close() + + var b bytes.Buffer + writer := multipart.NewWriter(&b) + part, err := writer.CreateFormFile("file", filepath.Base(filePath)) + if err != nil { + return nil, err + } + + _, err = io.Copy(part, file) + if err != nil { + return nil, err + } + writer.Close() + + url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, uploadPath) + req, err := http.NewRequest("POST", url, &b) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result, nil +} +``` + +## Phase 2: Data Model Implementation (Week 1-2) + +### Task 2.1: Complete Body Battery Implementation +**File:** `garth/data/body_battery.go` +**Reference:** `src/garth/data/body_battery/daily_stress.py` lines 55-77 + +Replace the stub `Get()` method: + +```go +func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (interface{}, error) { + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap, ok := response.(map[string]interface{}) + if !ok { + return nil, &errors.IOError{GarthError: errors.GarthError{ + Message: "Invalid response format"}} + } + + snakeResponse := utils.CamelToSnakeDict(responseMap) + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result DailyBodyBatteryStress + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return &result, nil +} +``` + +### Task 2.2: Complete Sleep Data Implementation +**File:** `garth/data/sleep.go` +**Reference:** `src/garth/data/sleep.py` lines 91-107 + +```go +func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (interface{}, error) { + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s", + client.Username, dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap := response.(map[string]interface{}) + snakeResponse := utils.CamelToSnakeDict(responseMap) + + dailySleepDto, exists := snakeResponse["daily_sleep_dto"].(map[string]interface{}) + if !exists || dailySleepDto["id"] == nil { + return nil, nil // No sleep data + } + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result struct { + DailySleepDTO *DailySleepDTO `json:"daily_sleep_dto"` + SleepMovement []SleepMovement `json:"sleep_movement"` + } + + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return result, nil +} +``` + +### Task 2.3: Complete HRV Implementation +**File:** `garth/data/hrv.go` +**Reference:** `src/garth/data/hrv.py` lines 68-78 + +```go +func (h *HRVData) Get(day time.Time, client *client.Client) (interface{}, error) { + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/hrv-service/hrv/%s", dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap := response.(map[string]interface{}) + snakeResponse := utils.CamelToSnakeDict(responseMap) + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result HRVData + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return &result, nil +} +``` + +### Task 2.4: Complete Weight Implementation +**File:** `garth/data/weight.go` +**Reference:** `src/garth/data/weight.py` lines 39-52 and 54-74 + +```go +func (w *WeightData) Get(day time.Time, client *client.Client) (interface{}, error) { + dateStr := day.Format("2006-01-02") + path := fmt.Sprintf("/weight-service/weight/dayview/%s", dateStr) + + response, err := client.ConnectAPI(path, "GET", nil) + if err != nil { + return nil, err + } + + if response == nil { + return nil, nil + } + + responseMap := response.(map[string]interface{}) + dayWeightList, exists := responseMap["dateWeightList"].([]interface{}) + if !exists || len(dayWeightList) == 0 { + return nil, nil + } + + // Get first weight entry + firstEntry := dayWeightList[0].(map[string]interface{}) + snakeResponse := utils.CamelToSnakeDict(firstEntry) + + jsonBytes, err := json.Marshal(snakeResponse) + if err != nil { + return nil, err + } + + var result WeightData + if err := json.Unmarshal(jsonBytes, &result); err != nil { + return nil, err + } + + return &result, nil +} +``` + +## Phase 3: Stats Module Implementation (Week 2) + +### Task 3.1: Create Stats Base +**File:** `garth/stats/base.go` (new file) +**Reference:** `src/garth/stats/_base.py` + +```go +package stats + +import ( + "fmt" + "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 +} +``` + +### Task 3.2: Create Individual Stats Types +**Files:** Create these files in `garth/stats/` +**Reference:** All files in `src/garth/stats/` + +**`steps.go`** (Reference: `src/garth/stats/steps.py`): +```go +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, + }, + } +} +``` + +**`stress.go`** (Reference: `src/garth/stats/stress.py`): +```go +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, + }, + } +} +``` + +Create similar files for: +- `hydration.go` → Reference `src/garth/stats/hydration.py` +- `intensity_minutes.go` → Reference `src/garth/stats/intensity_minutes.py` +- `sleep.go` → Reference `src/garth/stats/sleep.py` +- `hrv.go` → Reference `src/garth/stats/hrv.py` + +## Phase 4: Complete Data Interface Implementation (Week 2) + +### Task 4.1: Fix BaseData List Implementation +**File:** `garth/data/base.go` + +Update the List method to properly use the BaseData pattern: + +```go +func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) { + if maxWorkers < 1 { + maxWorkers = 10 // Match Python's MAX_WORKERS + } + + dates := utils.DateRange(end, days) + + var wg sync.WaitGroup + workCh := make(chan time.Time, days) + resultsCh := make(chan result, days) + + type result struct { + data interface{} + err error + } + + // Worker function + worker := func() { + defer wg.Done() + for date := range workCh { + data, err := b.Get(date, c) + resultsCh <- result{data: data, err: err} + } + } + + // Start workers + wg.Add(maxWorkers) + for i := 0; i < maxWorkers; i++ { + go worker() + } + + // Send work + go func() { + for _, date := range dates { + workCh <- date + } + close(workCh) + }() + + // Close results channel when workers are done + go func() { + wg.Wait() + close(resultsCh) + }() + + var results []interface{} + var errs []error + + for r := range resultsCh { + if r.err != nil { + errs = append(errs, r.err) + } else if r.data != nil { + results = append(results, r.data) + } + } + + return results, errs +} +``` + +## Phase 5: Testing and Documentation (Week 3) + +### Task 5.1: Create Integration Tests +**File:** `garth/integration_test.go` (new file) + +```go +package garth_test + +import ( + "testing" + "time" + "garmin-connect/garth/client" + "garmin-connect/garth/data" +) + +func TestBodyBatteryIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + c, err := client.NewClient("garmin.com") + require.NoError(t, err) + + // Load test session + err = c.LoadSession("test_session.json") + if err != nil { + t.Skip("No test session available") + } + + bb := &data.DailyBodyBatteryStress{} + result, err := bb.Get(time.Now().AddDate(0, 0, -1), c) + + assert.NoError(t, err) + if result != nil { + bbData := result.(*data.DailyBodyBatteryStress) + assert.NotZero(t, bbData.UserProfilePK) + } +} +``` + +### Task 5.2: Update Package Exports +**File:** `garth/__init__.go` (new file) + +Create a package-level API that matches Python's `__init__.py`: + +```go +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 + +// Main functions +var ( + NewClient = client.NewClient + Login = client.Login +) +``` + +## Implementation Checklist + +### Week 1 (Core Implementation): +- [ ] Client.ConnectAPI method +- [ ] Download/Upload methods +- [ ] Body Battery Get() implementation +- [ ] Sleep Data Get() implementation +- [ ] End-to-end test with real API + +### Week 2 (Complete Feature Set): +- [ ] HRV and Weight Get() implementations +- [ ] Complete stats module (all 7 types) +- [ ] BaseData List() method fix +- [ ] Integration tests + +### Week 3 (Polish and Documentation): +- [ ] Package-level exports +- [ ] README with examples +- [ ] Performance testing vs Python +- [ ] CLI tool verification + +## Key Implementation Notes + +1. **Error Handling**: Use the existing comprehensive error types +2. **Date Formats**: Always use `time.Time` and convert to "2006-01-02" for API calls +3. **Response Parsing**: Always use `utils.CamelToSnakeDict` before unmarshaling +4. **Concurrency**: The existing BaseData.List() handles worker pools correctly +5. **Testing**: Use `testutils.MockJSONResponse` for unit tests + +## Success Criteria + +Port is complete when: +- All Python data models have working Get() methods +- All Python stats types are implemented +- CLI tool outputs same format as Python +- Integration tests pass against real API +- Performance is equal or better than Python + +**Estimated Effort:** 2-3 weeks for junior developer with this detailed plan. \ No newline at end of file