mirror of
https://github.com/sstent/go-garth.git
synced 2025-12-05 23:51:42 +00:00
sync
This commit is contained in:
18
README.md
18
README.md
@@ -64,6 +64,8 @@ Available data types with Get() methods:
|
||||
|
||||
## Stats Types
|
||||
Available stats with List() methods:
|
||||
|
||||
### Daily Stats
|
||||
- `DailySteps`
|
||||
- `DailyStress`
|
||||
- `DailyHRV`
|
||||
@@ -71,6 +73,11 @@ Available stats with List() methods:
|
||||
- `DailyIntensityMinutes`
|
||||
- `DailySleep`
|
||||
|
||||
### Weekly Stats
|
||||
- `WeeklySteps`
|
||||
- `WeeklyStress`
|
||||
- `WeeklyHRV`
|
||||
|
||||
## Error Handling
|
||||
All methods return errors implementing:
|
||||
```go
|
||||
@@ -98,7 +105,12 @@ BenchmarkSleepList-8 50000 35124 ns/op (7 days)
|
||||
Full API docs: [https://pkg.go.dev/garmin-connect/garth](https://pkg.go.dev/garmin-connect/garth)
|
||||
|
||||
## CLI Tool
|
||||
Includes `cmd/garth` CLI for data export:
|
||||
Includes `cmd/garth` CLI for data export. Supports both daily and weekly stats:
|
||||
|
||||
```bash
|
||||
go run cmd/garth/main.go --email user@example.com --password pass \
|
||||
--data bodybattery --start 2023-01-01 --end 2023-01-07
|
||||
# Daily steps
|
||||
go run cmd/garth/main.go --data steps --period daily --start 2023-01-01 --end 2023-01-07
|
||||
|
||||
# Weekly stress
|
||||
go run cmd/garth/main.go --data stress --period weekly --start 2023-01-01 --end 2023-01-28
|
||||
```
|
||||
BIN
garmin-connect
Executable file
BIN
garmin-connect
Executable file
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "garmin.com",
|
||||
"username": "fbleagh",
|
||||
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU3MzgwNTAyLCJpYXQiOjE3NTcyNzcwMjAsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiZWVkMmQ2NTYtYWM0MC00NDdhLTkwYzEtOWMwMmQzNTM3MzZiIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.HwBbJysBlmSHtPLPtZ1SITqa6jZ8SFBuej7j--iYblHqgKwM7preEM03FgVJUusQi9SarB_lID-pjNdJ6MRGnYo3NSzO4wnmalmnoAxn-9pvAYHKznCIq5x4exC-2SlvW4paNK_-UzOd9mp23FNCvXcLGOc_lJlFuC20YAQID9x3ujIGm3PBWp6ycWIRydyvnNZga-a2opaZPjvC1TXKycNsUY1qZSc4hj3D7_wFrMtYuu2HuGQFeyNRNInA6Ir-J3i_OBFl-L4tM3CvpJAGUU7VEY257x8M6YvHL7xER0tv3FjTdqwLAkSMkBP9qH1mq7zPrVp6JEIqghI-EQVwsA"
|
||||
"auth_token": "bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImRpLW9hdXRoLXNpZ25lci1wcm9kLTIwMjQtcTEifQ.eyJzY29wZSI6WyJBVFBfUkVBRCIsIkFUUF9XUklURSIsIkNPTU1VTklUWV9DT1VSU0VfUkVBRCIsIkNPTU1VTklUWV9DT1VSU0VfV1JJVEUiLCJDT05ORUNUX01DVF9EQUlMWV9MT0dfUkVBRCIsIkNPTk5FQ1RfUkVBRCIsIkNPTk5FQ1RfV1JJVEUiLCJESVZFX0FQSV9SRUFEIiwiRElfT0FVVEhfMl9BVVRIT1JJWkFUSU9OX0NPREVfQ1JFQVRFIiwiRFRfQ0xJRU5UX0FOQUxZVElDU19XUklURSIsIkdBUk1JTlBBWV9SRUFEIiwiR0FSTUlOUEFZX1dSSVRFIiwiR0NPRkZFUl9SRUFEIiwiR0NPRkZFUl9XUklURSIsIkdIU19TQU1EIiwiR0hTX1VQTE9BRCIsIkdPTEZfQVBJX1JFQUQiLCJHT0xGX0FQSV9XUklURSIsIklOU0lHSFRTX1JFQUQiLCJJTlNJR0hUU19XUklURSIsIk9NVF9DQU1QQUlHTl9SRUFEIiwiT01UX1NVQlNDUklQVElPTl9SRUFEIiwiUFJPRFVDVF9TRUFSQ0hfUkVBRCJdLCJpc3MiOiJodHRwczovL2RpYXV0aC5nYXJtaW4uY29tIiwicmV2b2NhdGlvbl9lbGlnaWJpbGl0eSI6WyJHTE9CQUxfU0lHTk9VVCIsIk1BTkFHRURfU1RBVFVTIl0sImNsaWVudF90eXBlIjoiVU5ERUZJTkVEIiwiZXhwIjoxNzU4MjYzODU0LCJpYXQiOjE3NTgxOTkwMzUsImdhcm1pbl9ndWlkIjoiNTZlZTk1ZmMtMzY0MC00NGU2LTg1ODAtNDc4NDEwZDQwZGFhIiwianRpIjoiMjA5MTA2M2ItNTk0OC00MGM0LWE0YTEtMzI5ODY3ZWVmYjhlIiwiY2xpZW50X2lkIjoiR0FSTUlOX0NPTk5FQ1RfTU9CSUxFX0FORFJPSURfREkifQ.EdTSIGfBMnQJNX3HNGYRi_BM-JL5UlmrCriaxS5LSW4gdqq5j2oOzcHGwXIFxk2sNxjlBWZDQz5p8EdhDD9VG3wkGhtn51BwPlt4Nc0TwdVPtyvlfbYFZLSf6JPRqZT9bscAJRq38ObxjCFweRRIypUIWx78M9sF9Ubz1eJN8Z84qRrUjlToCvYoaink3_TJwHAEuHmaqJMfxwoKFk0LnQUF2wiDqvDoaYwkTbC0UoPqBtku_bYYou-2kolL9-l7VA696C8Mt9rEt9ydBb5DoC-8HKTrUKKBia4oIhRb9XumB7Hb9oE3NqGVVaxbXpZPibydRacMNAPI1O9N2PLQ3A"
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package client_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"garmin-connect/garth/testutils"
|
||||
@@ -25,7 +26,9 @@ func TestClient_Login_Success(t *testing.T) {
|
||||
// Create client with test configuration
|
||||
c, err := client.NewClient("example.com")
|
||||
require.NoError(t, err)
|
||||
c.Domain = ssoServer.URL
|
||||
// Set domain to just the host (without scheme)
|
||||
u, _ := url.Parse(ssoServer.URL)
|
||||
c.Domain = u.Host
|
||||
|
||||
// Perform login
|
||||
err = c.Login("test@example.com", "password")
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/errors"
|
||||
@@ -30,6 +35,13 @@ func NewClient(domain string) (*Client, error) {
|
||||
domain = "garmin.com"
|
||||
}
|
||||
|
||||
// Extract host without scheme if present
|
||||
if strings.Contains(domain, "://") {
|
||||
if u, err := url.Parse(domain); err == nil {
|
||||
domain = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, &errors.IOError{
|
||||
@@ -63,7 +75,15 @@ func NewClient(domain string) (*Client, error) {
|
||||
|
||||
// Login authenticates to Garmin Connect using SSO
|
||||
func (c *Client) Login(email, password string) error {
|
||||
ssoClient := sso.NewClient(c.Domain)
|
||||
// Extract host without scheme if present
|
||||
host := c.Domain
|
||||
if strings.Contains(host, "://") {
|
||||
if u, err := url.Parse(host); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
ssoClient := sso.NewClient(host)
|
||||
oauth2Token, mfaContext, err := ssoClient.Login(email, password)
|
||||
if err != nil {
|
||||
return &errors.AuthenticationError{
|
||||
@@ -102,7 +122,7 @@ func (c *Client) Login(email, password string) error {
|
||||
}
|
||||
|
||||
// GetUserProfile retrieves the current user's full profile
|
||||
func (c *Client) GetUserProfile() (*UserProfile, error) {
|
||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.Domain)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
@@ -146,7 +166,7 @@ func (c *Client) GetUserProfile() (*UserProfile, error) {
|
||||
}
|
||||
}
|
||||
|
||||
var profile UserProfile
|
||||
var profile types.UserProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
@@ -159,6 +179,153 @@ func (c *Client) GetUserProfile() (*UserProfile, error) {
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// ConnectAPI makes a raw API request to the Garmin Connect API
|
||||
func (c *Client) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: fmt.Sprintf("connectapi.%s", c.Domain),
|
||||
Path: path,
|
||||
RawQuery: params.Encode(),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "garth-go-client/1.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if body != nil && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Request failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(bodyBytes),
|
||||
GarthError: errors.GarthError{
|
||||
Message: fmt.Sprintf("API request failed with status %d: %s",
|
||||
resp.StatusCode, tryReadErrorBody(bytes.NewReader(bodyBytes))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func tryReadErrorBody(r io.Reader) string {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "failed to read error response"
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Upload sends a file to Garmin Connect
|
||||
func (c *Client) Upload(filePath string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to open file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create form file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to copy file content",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to close multipart writer",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = c.ConnectAPI("/upload-service/upload", "POST", nil, body)
|
||||
if err != nil {
|
||||
return &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "File upload failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download retrieves a file from Garmin Connect
|
||||
func (c *Client) Download(activityID string, filePath string) error {
|
||||
params := url.Values{}
|
||||
params.Add("activityId", activityID)
|
||||
|
||||
resp, err := c.ConnectAPI("/download-service/export", "GET", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, resp, 0644); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to save file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActivities retrieves recent activities
|
||||
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
||||
if limit <= 0 {
|
||||
|
||||
@@ -1,130 +1,4 @@
|
||||
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
|
||||
}
|
||||
// This file intentionally left blank.
|
||||
// All HTTP client methods are now implemented in client.go.
|
||||
|
||||
11
garth/client/http_client.go
Normal file
11
garth/client/http_client.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// HTTPClient defines the interface for HTTP operations
|
||||
type HTTPClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/errors"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||
@@ -116,30 +114,17 @@ func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (any,
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
if len(data) == 0 {
|
||||
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 {
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
// HRVSummary represents Heart Rate Variability summary data
|
||||
@@ -13,7 +17,9 @@ type HRVSummary struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
// Add other fields from Python implementation
|
||||
WeeklyAvg float64 `json:"weeklyAvg"`
|
||||
LastNightAvg float64 `json:"lastNightAvg"`
|
||||
Baseline float64 `json:"baseline"`
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
@@ -26,6 +32,16 @@ type HRVReading struct {
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return utils.ParseTimestamp(r.Timestamp)
|
||||
}
|
||||
|
||||
// RRSeconds converts the RR interval to seconds
|
||||
func (r *HRVReading) RRSeconds() float64 {
|
||||
return float64(r.RRInterval) / 1000.0
|
||||
}
|
||||
|
||||
// HRVData represents complete HRV data
|
||||
type HRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
@@ -34,19 +50,125 @@ type HRVData struct {
|
||||
BaseData
|
||||
}
|
||||
|
||||
// Validate ensures HRVSummary fields meet requirements
|
||||
func (h *HRVSummary) Validate() error {
|
||||
if h.WeeklyAvg < 0 {
|
||||
return errors.New("WeeklyAvg must be non-negative")
|
||||
}
|
||||
if h.LastNightAvg < 0 {
|
||||
return errors.New("LastNightAvg must be non-negative")
|
||||
}
|
||||
if h.Baseline < 0 {
|
||||
return errors.New("Baseline must be non-negative")
|
||||
}
|
||||
if h.CalendarDate.IsZero() {
|
||||
return errors.New("CalendarDate must be set")
|
||||
}
|
||||
if h.StartTimestampGMT.IsZero() || h.EndTimestampGMT.IsZero() {
|
||||
return errors.New("Timestamps must be set")
|
||||
}
|
||||
if h.EndTimestampGMT.Before(h.StartTimestampGMT) {
|
||||
return errors.New("EndTimestampGMT must be after StartTimestampGMT")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate ensures HRVReading fields meet requirements
|
||||
func (r *HRVReading) Validate() error {
|
||||
if r.StressLevel < 0 || r.StressLevel > 100 {
|
||||
return fmt.Errorf("StressLevel must be between 0-100, got %d", r.StressLevel)
|
||||
}
|
||||
if r.HeartRate <= 0 {
|
||||
return fmt.Errorf("HeartRate must be positive, got %d", r.HeartRate)
|
||||
}
|
||||
if r.RRInterval <= 0 {
|
||||
return fmt.Errorf("RRInterval must be positive, got %d", r.RRInterval)
|
||||
}
|
||||
if r.SignalQuality < 0 || r.SignalQuality > 1 {
|
||||
return fmt.Errorf("SignalQuality must be between 0-1, got %f", r.SignalQuality)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate ensures HRVData meets all requirements
|
||||
func (h *HRVData) Validate() error {
|
||||
if h.UserProfilePK <= 0 {
|
||||
return errors.New("UserProfilePK must be positive")
|
||||
}
|
||||
if err := h.HRVSummary.Validate(); err != nil {
|
||||
return fmt.Errorf("HRVSummary validation failed: %w", err)
|
||||
}
|
||||
for i, reading := range h.HRVReadings {
|
||||
if err := reading.Validate(); err != nil {
|
||||
return fmt.Errorf("HRVReading[%d] validation failed: %w", i, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DailyVariability calculates the average RR interval for the day
|
||||
func (h *HRVData) DailyVariability() float64 {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return 0
|
||||
}
|
||||
var total float64
|
||||
for _, r := range h.HRVReadings {
|
||||
total += r.RRSeconds()
|
||||
}
|
||||
return total / float64(len(h.HRVReadings))
|
||||
}
|
||||
|
||||
// MinHRVReading returns the reading with the lowest RR interval
|
||||
func (h *HRVData) MinHRVReading() HRVReading {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return HRVReading{}
|
||||
}
|
||||
min := h.HRVReadings[0]
|
||||
for _, r := range h.HRVReadings {
|
||||
if r.RRInterval < min.RRInterval {
|
||||
min = r
|
||||
}
|
||||
}
|
||||
return min
|
||||
}
|
||||
|
||||
// MaxHRVReading returns the reading with the highest RR interval
|
||||
func (h *HRVData) MaxHRVReading() HRVReading {
|
||||
if len(h.HRVReadings) == 0 {
|
||||
return HRVReading{}
|
||||
}
|
||||
max := h.HRVReadings[0]
|
||||
for _, r := range h.HRVReadings {
|
||||
if r.RRInterval > max.RRInterval {
|
||||
max = r
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
// ParseHRVReadings converts values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
readings := make([]HRVReading, 0)
|
||||
readings := make([]HRVReading, 0, len(valuesArray))
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 6 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract values with type assertions
|
||||
// Add parsing logic based on Python implementation
|
||||
timestamp, _ := values[0].(int)
|
||||
stressLevel, _ := values[1].(int)
|
||||
heartRate, _ := values[2].(int)
|
||||
rrInterval, _ := values[3].(int)
|
||||
status, _ := values[4].(string)
|
||||
signalQuality, _ := values[5].(float64)
|
||||
|
||||
readings = append(readings, HRVReading{
|
||||
// Initialize fields
|
||||
Timestamp: timestamp,
|
||||
StressLevel: stressLevel,
|
||||
HeartRate: heartRate,
|
||||
RRInterval: rrInterval,
|
||||
Status: status,
|
||||
SignalQuality: signalQuality,
|
||||
})
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
@@ -57,8 +179,29 @@ func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
|
||||
// Get implements the Data interface for HRVData
|
||||
func (h *HRVData) 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/dailyHrvData/%s?date=%s",
|
||||
client.Username, dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var hrvData HRVData
|
||||
if err := json.Unmarshal(data, &hrvData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := hrvData.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("HRV data validation failed: %w", err)
|
||||
}
|
||||
|
||||
return hrvData, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/errors"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
// SleepScores represents sleep scoring data
|
||||
@@ -45,43 +43,28 @@ func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
client.Username, dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap, ok := response.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Invalid response format"}}
|
||||
var response struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
}
|
||||
|
||||
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 {
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"daily_sleep_dto"`
|
||||
SleepMovement []SleepMovement `json:"sleep_movement"`
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
@@ -15,25 +16,66 @@ type WeightData struct {
|
||||
Weight float64 `json:"weight"` // in kilograms
|
||||
BMI float64 `json:"bmi"`
|
||||
BodyFatPercentage float64 `json:"bodyFatPercentage"`
|
||||
BoneMass float64 `json:"boneMass"` // in kg
|
||||
MuscleMass float64 `json:"muscleMass"` // in kg
|
||||
Hydration float64 `json:"hydration"` // in kg
|
||||
BaseData
|
||||
}
|
||||
|
||||
// Validate checks if weight data contains valid values
|
||||
func (w *WeightData) Validate() error {
|
||||
if w.Weight <= 0 {
|
||||
return errors.New("invalid weight value")
|
||||
return fmt.Errorf("invalid weight value")
|
||||
}
|
||||
if w.BMI < 10 || w.BMI > 50 {
|
||||
return fmt.Errorf("BMI out of valid range")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get implements the Data interface for WeightData
|
||||
func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
// Implementation to be added
|
||||
return nil, nil
|
||||
startDate := day.Format("2006-01-02")
|
||||
endDate := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/weight-service/weight/dateRange?startDate=%s&endDate=%s",
|
||||
startDate, endDate)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
WeightList []WeightData `json:"weightList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(response.WeightList) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
weightData := response.WeightList[0]
|
||||
// Convert grams to kilograms
|
||||
weightData.Weight = weightData.Weight / 1000
|
||||
weightData.BoneMass = weightData.BoneMass / 1000
|
||||
weightData.MuscleMass = weightData.MuscleMass / 1000
|
||||
weightData.Hydration = weightData.Hydration / 1000
|
||||
|
||||
return weightData, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (w *WeightData) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
results, errs := w.BaseData.List(end, days, client, maxWorkers)
|
||||
if len(errs) > 0 {
|
||||
// Return first error for now
|
||||
return results, errs[0]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
@@ -59,12 +59,15 @@ func TestStatsEndpoints(t *testing.T) {
|
||||
name string
|
||||
stat stats.Stats
|
||||
}{
|
||||
{"Steps", stats.NewDailySteps()},
|
||||
{"Stress", stats.NewDailyStress()},
|
||||
{"Hydration", stats.NewDailyHydration()},
|
||||
{"IntensityMinutes", stats.NewDailyIntensityMinutes()},
|
||||
{"Sleep", stats.NewDailySleep()},
|
||||
{"HRV", stats.NewDailyHRV()},
|
||||
{"DailySteps", stats.NewDailySteps()},
|
||||
{"DailyStress", stats.NewDailyStress()},
|
||||
{"DailyHydration", stats.NewDailyHydration()},
|
||||
{"DailyIntensityMinutes", stats.NewDailyIntensityMinutes()},
|
||||
{"DailySleep", stats.NewDailySleep()},
|
||||
{"DailyHRV", stats.NewDailyHRV()},
|
||||
{"WeeklySteps", stats.NewWeeklySteps()},
|
||||
{"WeeklyStress", stats.NewWeeklyStress()},
|
||||
{"WeeklyHRV", stats.NewWeeklyHRV()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -92,3 +95,41 @@ func TestStatsEndpoints(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagination(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)
|
||||
}
|
||||
|
||||
err = c.LoadSession("test_session.json")
|
||||
if err != nil {
|
||||
t.Skip("No test session available")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
stat stats.Stats
|
||||
period int
|
||||
}{
|
||||
{"DailySteps_30", stats.NewDailySteps(), 30},
|
||||
{"WeeklySteps_60", stats.NewWeeklySteps(), 60},
|
||||
}
|
||||
|
||||
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, tt.period, c)
|
||||
if err != nil {
|
||||
t.Errorf("List failed: %v", err)
|
||||
}
|
||||
if len(results) != tt.period {
|
||||
t.Errorf("Expected %d results, got %d", tt.period, len(results))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,26 +21,34 @@ type BaseStats struct {
|
||||
|
||||
func (b *BaseStats) List(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
endDate := utils.FormatEndDate(end)
|
||||
var allData []interface{}
|
||||
var errs []error
|
||||
|
||||
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
|
||||
for period > 0 {
|
||||
pageSize := b.PageSize
|
||||
if period < pageSize {
|
||||
pageSize = period
|
||||
}
|
||||
|
||||
// Get remaining pages recursively
|
||||
remainingStart := endDate.AddDate(0, 0, -b.PageSize)
|
||||
remainingPeriod := period - b.PageSize
|
||||
remainingData, err := b.List(remainingStart, remainingPeriod, client)
|
||||
page, err := b.fetchPage(endDate, pageSize, client)
|
||||
if err != nil {
|
||||
return page, err
|
||||
errs = append(errs, err)
|
||||
// Continue to next page even if current fails
|
||||
} else {
|
||||
allData = append(page, allData...)
|
||||
}
|
||||
|
||||
return append(remainingData, page...), nil
|
||||
// Move to previous page
|
||||
endDate = endDate.AddDate(0, 0, -pageSize)
|
||||
period -= pageSize
|
||||
}
|
||||
|
||||
return b.fetchPage(endDate, period, client)
|
||||
// Return partial data with aggregated errors
|
||||
var finalErr error
|
||||
if len(errs) > 0 {
|
||||
finalErr = fmt.Errorf("partial failure: %v", errs)
|
||||
}
|
||||
return allData, finalErr
|
||||
}
|
||||
|
||||
func (b *BaseStats) fetchPage(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
@@ -55,24 +64,26 @@ func (b *BaseStats) fetchPage(end time.Time, period int, client *client.Client)
|
||||
path = strings.Replace(path, "{period}", fmt.Sprintf("%d", period), 1)
|
||||
}
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
if len(data) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
responseSlice, ok := response.([]interface{})
|
||||
if !ok || len(responseSlice) == 0 {
|
||||
var responseSlice []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &responseSlice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(responseSlice) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
var results []interface{}
|
||||
for _, item := range responseSlice {
|
||||
itemMap := item.(map[string]interface{})
|
||||
|
||||
for _, itemMap := range responseSlice {
|
||||
// Handle nested "values" structure
|
||||
if values, exists := itemMap["values"]; exists {
|
||||
valuesMap := values.(map[string]interface{})
|
||||
|
||||
40
garth/stats/hrv_weekly.go
Normal file
40
garth/stats/hrv_weekly.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WEEKLY_HRV_PATH = "/wellness-service/wellness/weeklyHrv"
|
||||
|
||||
type WeeklyHRV struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
AverageHRV float64 `json:"average_hrv"`
|
||||
MaxHRV float64 `json:"max_hrv"`
|
||||
MinHRV float64 `json:"min_hrv"`
|
||||
HRVQualifier string `json:"hrv_qualifier"`
|
||||
WellnessDataDaysCount int `json:"wellness_data_days_count"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklyHRV() *WeeklyHRV {
|
||||
return &WeeklyHRV{
|
||||
BaseStats: BaseStats{
|
||||
Path: WEEKLY_HRV_PATH + "/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeeklyHRV) Validate() error {
|
||||
if w.CalendarDate.IsZero() {
|
||||
return errors.New("calendar_date is required")
|
||||
}
|
||||
if w.AverageHRV < 0 || w.MaxHRV < 0 || w.MinHRV < 0 {
|
||||
return errors.New("HRV values must be non-negative")
|
||||
}
|
||||
if w.MaxHRV < w.MinHRV {
|
||||
return errors.New("max_hrv must be greater than min_hrv")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
36
garth/stats/stress_weekly.go
Normal file
36
garth/stats/stress_weekly.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
const WEEKLY_STRESS_PATH = "/wellness-service/wellness/weeklyStress"
|
||||
|
||||
type WeeklyStress struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalStressDuration int `json:"total_stress_duration"`
|
||||
AverageStressLevel float64 `json:"average_stress_level"`
|
||||
MaxStressLevel int `json:"max_stress_level"`
|
||||
StressQualifier string `json:"stress_qualifier"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklyStress() *WeeklyStress {
|
||||
return &WeeklyStress{
|
||||
BaseStats: BaseStats{
|
||||
Path: WEEKLY_STRESS_PATH + "/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WeeklyStress) Validate() error {
|
||||
if w.CalendarDate.IsZero() {
|
||||
return errors.New("calendar_date is required")
|
||||
}
|
||||
if w.TotalStressDuration < 0 {
|
||||
return errors.New("total_stress_duration must be non-negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
24
garth/testutils/mock_client.go
Normal file
24
garth/testutils/mock_client.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
)
|
||||
|
||||
// MockClient simulates API client for tests
|
||||
type MockClient struct {
|
||||
RealClient *client.Client
|
||||
FailEvery int
|
||||
counter int
|
||||
}
|
||||
|
||||
func (mc *MockClient) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
mc.counter++
|
||||
if mc.FailEvery != 0 && mc.counter%mc.FailEvery == 0 {
|
||||
return nil, errors.New("simulated error")
|
||||
}
|
||||
return mc.RealClient.ConnectAPI(path, method, params, body)
|
||||
}
|
||||
@@ -1,10 +1,30 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON handles Garmin's timestamp format
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := time.Parse("2006-01-02T15:04:05", s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
|
||||
// Client represents the Garmin Connect client
|
||||
type Client struct {
|
||||
Domain string
|
||||
@@ -65,6 +85,14 @@ type OAuth1Token struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// OAuth2Token represents OAuth2 token response
|
||||
type OAuth2Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
38
garth/utils/timeutils.go
Normal file
38
garth/utils/timeutils.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// SetDefaultLocation sets the default time location for conversions
|
||||
func SetDefaultLocation(loc *time.Location) {
|
||||
defaultLocation = loc
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ToLocalTime converts UTC time to local time using default location
|
||||
func ToLocalTime(utcTime time.Time) time.Time {
|
||||
return utcTime.In(defaultLocation)
|
||||
}
|
||||
|
||||
// ToUTCTime converts local time to UTC
|
||||
func ToUTCTime(localTime time.Time) time.Time {
|
||||
return localTime.UTC()
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"garmin-connect/garth/types"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -13,8 +14,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/types"
|
||||
)
|
||||
|
||||
var oauthConsumer *types.OAuthConsumer
|
||||
|
||||
187
portingplan_3.md
Normal file
187
portingplan_3.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Implementation Plan for Garmin Connect Go Client - Feature Parity
|
||||
|
||||
## Phase 1: Complete Core Data Types (Priority: High)
|
||||
|
||||
### 1.1 Complete HRV Data Implementation
|
||||
**File**: `garth/data/hrv.go`
|
||||
**Reference**: Python `garth/hrv.py` and API examples in README
|
||||
|
||||
**Tasks**:
|
||||
- Implement `Get()` method calling `/wellness-service/wellness/dailyHrvData/{username}?date={date}`
|
||||
- Complete `ParseHRVReadings()` function based on Python parsing logic
|
||||
- Add missing fields to `HRVSummary` struct (reference Python HRVSummary dataclass)
|
||||
- Implement `List()` method using BaseData pattern
|
||||
|
||||
### 1.2 Complete Weight Data Implementation
|
||||
**File**: `garth/data/weight.go`
|
||||
**Reference**: Python `garth/weight.py`
|
||||
|
||||
**Tasks**:
|
||||
- Implement `Get()` method calling `/weight-service/weight/dateRange?startDate={date}&endDate={date}`
|
||||
- Add all missing fields from Python WeightData dataclass
|
||||
- Implement proper unit conversions (grams vs kg)
|
||||
- Add `List()` method for date ranges
|
||||
|
||||
### 1.3 Complete Sleep Data Implementation
|
||||
**File**: `garth/data/sleep.go`
|
||||
**Reference**: Python `garth/sleep.py`
|
||||
|
||||
**Tasks**:
|
||||
- Fix `Get()` method to properly parse nested sleep data structures
|
||||
- Add missing `SleepScores` fields from Python implementation
|
||||
- Implement sleep quality calculations and derived properties
|
||||
- Add proper timezone handling for sleep timestamps
|
||||
|
||||
## Phase 2: Add Missing Core API Methods (Priority: High)
|
||||
|
||||
### 2.1 Add ConnectAPI Method
|
||||
**File**: `garth/client/client.go`
|
||||
**Reference**: Python `garth/client.py` `connectapi()` method
|
||||
|
||||
**Tasks**:
|
||||
- Add `ConnectAPI(path, params, method)` method to Client struct
|
||||
- Support GET/POST with query parameters and JSON body
|
||||
- Return raw JSON response for flexible endpoint access
|
||||
- Add proper error handling and authentication headers
|
||||
|
||||
### 2.2 Add File Operations
|
||||
**File**: `garth/client/client.go`
|
||||
**Reference**: Python `garth/client.py` upload/download methods
|
||||
|
||||
**Tasks**:
|
||||
- Complete `Upload()` method for FIT file uploads to `/upload-service/upload`
|
||||
- Add `Download()` method for activity exports
|
||||
- Handle multipart form uploads properly
|
||||
- Add progress callbacks for large files
|
||||
|
||||
## Phase 3: Complete Stats Implementation (Priority: Medium)
|
||||
|
||||
### 3.1 Fix Stats Pagination
|
||||
**File**: `garth/stats/base.go`
|
||||
**Reference**: Python `garth/stats.py` pagination logic
|
||||
|
||||
**Tasks**:
|
||||
- Fix recursive pagination in `BaseStats.List()` method
|
||||
- Ensure proper date range handling for >28 day requests
|
||||
- Add proper error handling for missing data pages
|
||||
- Test with large date ranges (>365 days)
|
||||
|
||||
### 3.2 Add Missing Stats Types
|
||||
**Files**: `garth/stats/` directory
|
||||
**Reference**: Python `garth/stats/` directory
|
||||
|
||||
**Tasks**:
|
||||
- Add `WeeklySteps`, `WeeklyStress`, `WeeklyHRV` types
|
||||
- Implement monthly and yearly aggregation types if present in Python
|
||||
- Add any missing daily stats types by comparing Python vs Go stats files
|
||||
|
||||
## Phase 4: Add Advanced Features (Priority: Medium)
|
||||
|
||||
### 4.1 Add Data Validation
|
||||
**Files**: All data types
|
||||
**Reference**: Python Pydantic dataclass validators
|
||||
|
||||
**Tasks**:
|
||||
- Add `Validate()` methods to all data structures
|
||||
- Implement field validation rules from Python Pydantic models
|
||||
- Add data sanitization for API responses
|
||||
- Handle missing/null fields gracefully
|
||||
|
||||
### 4.2 Add Derived Properties
|
||||
**Files**: `garth/data/` directory
|
||||
**Reference**: Python dataclass `@property` methods
|
||||
|
||||
**Tasks**:
|
||||
- Add calculated fields to BodyBattery (current_level, max_level, min_level, battery_change)
|
||||
- Add sleep duration calculations and sleep efficiency
|
||||
- Add stress level aggregations and summaries
|
||||
- Implement timezone-aware timestamp helpers
|
||||
|
||||
## Phase 5: Enhanced Error Handling & Logging (Priority: Low)
|
||||
|
||||
### 5.1 Improve Error Types
|
||||
**File**: `garth/errors/errors.go`
|
||||
**Reference**: Python `garth/exc.py`
|
||||
|
||||
**Tasks**:
|
||||
- Add specific error types for rate limiting, MFA required, etc.
|
||||
- Implement error retry logic with exponential backoff
|
||||
- Add request/response logging for debugging
|
||||
- Handle partial failures in List() operations
|
||||
|
||||
### 5.2 Add Configuration Options
|
||||
**File**: `garth/client/client.go`
|
||||
**Reference**: Python `garth/configure.py`
|
||||
|
||||
**Tasks**:
|
||||
- Add proxy support configuration
|
||||
- Add custom timeout settings
|
||||
- Add SSL verification options
|
||||
- Add custom user agent configuration
|
||||
|
||||
## Phase 6: Testing & Documentation (Priority: Medium)
|
||||
|
||||
### 6.1 Add Integration Tests
|
||||
**File**: `garth/integration_test.go`
|
||||
**Reference**: Python test files
|
||||
|
||||
**Tasks**:
|
||||
- Add real API tests with saved session files
|
||||
- Test all data types with real Garmin data
|
||||
- Add benchmark comparisons with Python timings
|
||||
- Test error scenarios and edge cases
|
||||
|
||||
### 6.2 Add Usage Examples
|
||||
**Files**: `examples/` directory (create new)
|
||||
**Reference**: Python README examples
|
||||
|
||||
**Tasks**:
|
||||
- Port all Python README examples to Go
|
||||
- Add Jupyter notebook equivalent examples
|
||||
- Create data export utilities matching Python functionality
|
||||
- Add data visualization examples using Go libraries
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Code Standards
|
||||
- Follow existing Go package structure
|
||||
- Use existing error handling patterns
|
||||
- Maintain interface compatibility where possible
|
||||
- Add comprehensive godoc comments
|
||||
|
||||
### Testing Strategy
|
||||
- Add unit tests for each new method
|
||||
- Use table-driven tests for data parsing
|
||||
- Mock HTTP responses for reliable testing
|
||||
- Test timezone handling thoroughly
|
||||
|
||||
### Data Structure Mapping
|
||||
- Compare Python dataclass fields to Go struct fields
|
||||
- Ensure JSON tag mapping matches API responses
|
||||
- Handle optional fields with pointers (`*int`, `*string`)
|
||||
- Use proper Go time.Time for timestamps
|
||||
|
||||
### API Endpoint Discovery
|
||||
- Check Python source for endpoint URLs
|
||||
- Verify parameter names and formats
|
||||
- Test with actual API calls using saved sessions
|
||||
- Document any API differences found
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
Each phase is complete when:
|
||||
1. All methods have working implementations (no `return nil, nil`)
|
||||
2. Unit tests pass with >80% coverage
|
||||
3. Integration tests pass with real API data
|
||||
4. Documentation includes usage examples
|
||||
5. Benchmarks show performance is maintained or improved
|
||||
|
||||
## Estimated Timeline
|
||||
- Phase 1: 2-3 weeks
|
||||
- Phase 2: 1-2 weeks
|
||||
- Phase 3: 1 week
|
||||
- Phase 4: 2 weeks
|
||||
- Phase 5: 1 week
|
||||
- Phase 6: 1 week
|
||||
|
||||
**Total**: 8-10 weeks for complete feature parity
|
||||
Reference in New Issue
Block a user