mirror of
https://github.com/sstent/go-garth.git
synced 2026-03-13 15:35:21 +00:00
sync - build broken
This commit is contained in:
@@ -10,7 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/rodaine/table"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -159,19 +159,18 @@ func runListActivities(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"ID", "Name", "Type", "Date", "Distance (km)", "Duration (s)"})
|
||||
tbl := table.New("ID", "Name", "Type", "Date", "Distance (km)", "Duration (s)")
|
||||
for _, activity := range activities {
|
||||
table.Append([]string{
|
||||
tbl.AddRow(
|
||||
fmt.Sprintf("%d", activity.ActivityID),
|
||||
activity.ActivityName,
|
||||
activity.ActivityType.TypeKey,
|
||||
activity.StartTimeLocal.Format("2006-01-02 15:04:05"),
|
||||
fmt.Sprintf("%.2f", activity.Distance/1000),
|
||||
fmt.Sprintf("%.0f", activity.Duration),
|
||||
})
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -203,7 +202,7 @@ func runGetActivity(cmd *cobra.Command, args []string) error {
|
||||
|
||||
fmt.Printf("Activity Details (ID: %d):\n", activityDetail.ActivityID)
|
||||
fmt.Printf(" Name: %s\n", activityDetail.ActivityName)
|
||||
fmt.Printf(" Type: %s\n", activityDetail.ActivityType)
|
||||
fmt.Printf(" Type: %s\n", activityDetail.ActivityType.TypeKey)
|
||||
fmt.Printf(" Date: %s\n", activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05"))
|
||||
fmt.Printf(" Distance: %.2f km\n", activityDetail.Distance/1000)
|
||||
fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration)
|
||||
|
||||
@@ -8,12 +8,11 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/rodaine/table"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
types "go-garth/internal/types"
|
||||
"go-garth/internal/utils"
|
||||
types "go-garth/internal/models/types"
|
||||
"go-garth/pkg/garmin"
|
||||
)
|
||||
|
||||
@@ -116,6 +115,35 @@ func init() {
|
||||
|
||||
healthCmd.AddCommand(hrZonesCmd)
|
||||
|
||||
// Training Status Command
|
||||
trainingStatusCmd = &cobra.Command{
|
||||
Use: "training-status",
|
||||
Short: "Get Training Status data",
|
||||
Long: `Fetch Training Status data.`,
|
||||
RunE: runTrainingStatus,
|
||||
}
|
||||
healthCmd.AddCommand(trainingStatusCmd)
|
||||
trainingStatusCmd.Flags().StringVar(&healthDateFrom, "from", "", "Date for data fetching (YYYY-MM-DD, defaults to today)")
|
||||
|
||||
// Training Load Command
|
||||
trainingLoadCmd = &cobra.Command{
|
||||
Use: "training-load",
|
||||
Short: "Get Training Load data",
|
||||
Long: `Fetch Training Load data.`,
|
||||
RunE: runTrainingLoad,
|
||||
}
|
||||
healthCmd.AddCommand(trainingLoadCmd)
|
||||
trainingLoadCmd.Flags().StringVar(&healthDateFrom, "from", "", "Date for data fetching (YYYY-MM-DD, defaults to today)")
|
||||
|
||||
// Fitness Age Command
|
||||
fitnessAgeCmd = &cobra.Command{
|
||||
Use: "fitness-age",
|
||||
Short: "Get Fitness Age data",
|
||||
Long: `Fetch Fitness Age data.`,
|
||||
RunE: runFitnessAge,
|
||||
}
|
||||
healthCmd.AddCommand(fitnessAgeCmd)
|
||||
|
||||
// Wellness Command
|
||||
wellnessCmd = &cobra.Command{
|
||||
Use: "wellness",
|
||||
@@ -131,13 +159,12 @@ func init() {
|
||||
}
|
||||
|
||||
func runSleep(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
sessionFile := "garmin_session.json" // TODO: Make session file configurable
|
||||
if err := garminClient.LoadSession(sessionFile); err != nil {
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
@@ -161,67 +188,27 @@ func runSleep(cmd *cobra.Command, args []string) error {
|
||||
endDate = time.Now() // Default to today
|
||||
}
|
||||
|
||||
sleepData, err := garminClient.GetSleepData(startDate, endDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sleep data: %w", err)
|
||||
var allSleepData []*types.DetailedSleepData
|
||||
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
|
||||
sleepData, err := garminClient.GetDetailedSleepData(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sleep data for %s: %w", d.Format("2006-01-02"), err)
|
||||
}
|
||||
if sleepData != nil {
|
||||
allSleepData = append(allSleepData, sleepData)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sleepData) == 0 {
|
||||
if len(allSleepData) == 0 {
|
||||
fmt.Println("No sleep data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply aggregation if requested
|
||||
if healthAggregate != "" {
|
||||
aggregatedSleep := make(map[string]struct {
|
||||
TotalSleepSeconds int
|
||||
SleepScore int
|
||||
Count int
|
||||
})
|
||||
|
||||
for _, data := range sleepData {
|
||||
key := ""
|
||||
switch healthAggregate {
|
||||
case "day":
|
||||
key = data.Date.Format("2006-01-02")
|
||||
case "week":
|
||||
year, week := data.Date.ISOWeek()
|
||||
key = fmt.Sprintf("%d-W%02d", year, week)
|
||||
case "month":
|
||||
key = data.Date.Format("2006-01")
|
||||
case "year":
|
||||
key = data.Date.Format("2006")
|
||||
default:
|
||||
return fmt.Errorf("unsupported aggregation period: %s", healthAggregate)
|
||||
}
|
||||
|
||||
entry := aggregatedSleep[key]
|
||||
entry.TotalSleepSeconds += data.TotalSleepSeconds
|
||||
entry.SleepScore += data.SleepScore
|
||||
entry.Count++
|
||||
aggregatedSleep[key] = entry
|
||||
}
|
||||
|
||||
// Convert aggregated data back to a slice for output
|
||||
sleepData = []types.SleepData{}
|
||||
for key, entry := range aggregatedSleep {
|
||||
sleepData = append(sleepData, types.SleepData{
|
||||
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||
TotalSleepSeconds: entry.TotalSleepSeconds / entry.Count,
|
||||
SleepScore: entry.SleepScore / entry.Count,
|
||||
DeepSleepSeconds: 0,
|
||||
LightSleepSeconds: 0,
|
||||
RemSleepSeconds: 0,
|
||||
AwakeSleepSeconds: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output")
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(sleepData, "", " ")
|
||||
data, err := json.MarshalIndent(allSleepData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal sleep data to JSON: %w", err)
|
||||
}
|
||||
@@ -230,33 +217,38 @@ func runSleep(cmd *cobra.Command, args []string) error {
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "SleepScore", "TotalSleepSeconds", "DeepSleepSeconds", "LightSleepSeconds", "RemSleepSeconds", "AwakeSleepSeconds"})
|
||||
for _, data := range sleepData {
|
||||
writer.Write([]string{"Date", "SleepScore", "TotalSleep", "Deep", "Light", "REM", "Awake", "AvgSpO2", "LowestSpO2", "AvgRespiration"})
|
||||
for _, data := range allSleepData {
|
||||
writer.Write([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.SleepScore),
|
||||
fmt.Sprintf("%d", data.TotalSleepSeconds),
|
||||
fmt.Sprintf("%d", data.DeepSleepSeconds),
|
||||
fmt.Sprintf("%d", data.LightSleepSeconds),
|
||||
fmt.Sprintf("%d", data.RemSleepSeconds),
|
||||
fmt.Sprintf("%d", data.AwakeSleepSeconds),
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake"})
|
||||
for _, data := range sleepData {
|
||||
table.Append([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.SleepScore),
|
||||
(time.Duration(data.TotalSleepSeconds) * time.Second).String(),
|
||||
data.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.SleepScores.Overall),
|
||||
(time.Duration(data.DeepSleepSeconds+data.LightSleepSeconds+data.RemSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.DeepSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.LightSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.RemSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.AwakeSleepSeconds) * time.Second).String(),
|
||||
func() string { if data.AverageSpO2Value != nil { return fmt.Sprintf("%.2f", *data.AverageSpO2Value) } ; return "N/A" }(),
|
||||
func() string { if data.LowestSpO2Value != nil { return fmt.Sprintf("%d", *data.LowestSpO2Value) } ; return "N/A" }(),
|
||||
func() string { if data.AverageRespirationValue != nil { return fmt.Sprintf("%.2f", *data.AverageRespirationValue) } ; return "N/A" }(),
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
case "table":
|
||||
tbl := table.New("Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake", "Avg SpO2", "Lowest SpO2", "Avg Resp")
|
||||
for _, data := range allSleepData {
|
||||
tbl.AddRow(
|
||||
data.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.SleepScores.Overall),
|
||||
(time.Duration(data.DeepSleepSeconds+data.LightSleepSeconds+data.RemSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.DeepSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.LightSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.RemSleepSeconds) * time.Second).String(),
|
||||
(time.Duration(data.AwakeSleepSeconds) * time.Second).String(),
|
||||
func() string { if data.AverageSpO2Value != nil { return fmt.Sprintf("%.2f", *data.AverageSpO2Value) } ; return "N/A" }(),
|
||||
func() string { if data.LowestSpO2Value != nil { return fmt.Sprintf("%d", *data.LowestSpO2Value) } ; return "N/A" }(),
|
||||
func() string { if data.AverageRespirationValue != nil { return fmt.Sprintf("%.2f", *data.AverageRespirationValue) } ; return "N/A" }(),
|
||||
)
|
||||
}
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -265,13 +257,12 @@ func runSleep(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runHrv(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
sessionFile := "garmin_session.json" // TODO: Make session file configurable
|
||||
if err := garminClient.LoadSession(sessionFile); err != nil {
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
@@ -280,60 +271,27 @@ func runHrv(cmd *cobra.Command, args []string) error {
|
||||
days = 7 // Default to 7 days if not specified
|
||||
}
|
||||
|
||||
hrvData, err := garminClient.GetHrvData(days)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get HRV data: %w", err)
|
||||
var allHrvData []*types.DailyHRVData
|
||||
for d := time.Now().AddDate(0, 0, -days+1); !d.After(time.Now()); d = d.AddDate(0, 0, 1) {
|
||||
hrvData, err := garminClient.GetDailyHRVData(d)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get HRV data for %s: %w", d.Format("2006-01-02"), err)
|
||||
}
|
||||
if hrvData != nil {
|
||||
allHrvData = append(allHrvData, hrvData)
|
||||
}
|
||||
}
|
||||
|
||||
if len(hrvData) == 0 {
|
||||
if len(allHrvData) == 0 {
|
||||
fmt.Println("No HRV data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply aggregation if requested
|
||||
if healthAggregate != "" {
|
||||
aggregatedHrv := make(map[string]struct {
|
||||
HrvValue float64
|
||||
Count int
|
||||
})
|
||||
|
||||
for _, data := range hrvData {
|
||||
key := ""
|
||||
switch healthAggregate {
|
||||
case "day":
|
||||
key = data.Date.Format("2006-01-02")
|
||||
case "week":
|
||||
year, week := data.Date.ISOWeek()
|
||||
key = fmt.Sprintf("%d-W%02d", year, week)
|
||||
case "month":
|
||||
key = data.Date.Format("2006-01")
|
||||
case "year":
|
||||
key = data.Date.Format("2006")
|
||||
default:
|
||||
return fmt.Errorf("unsupported aggregation period: %s", healthAggregate)
|
||||
}
|
||||
|
||||
entry := aggregatedHrv[key]
|
||||
entry.HrvValue += data.HrvValue
|
||||
entry.Count++
|
||||
aggregatedHrv[key] = entry
|
||||
}
|
||||
|
||||
// Convert aggregated data back to a slice for output
|
||||
hrvData = []types.HrvData{}
|
||||
for key, entry := range aggregatedHrv {
|
||||
hrvData = append(hrvData, types.HrvData{
|
||||
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||
HrvValue: entry.HrvValue / float64(entry.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output")
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(hrvData, "", " ")
|
||||
data, err := json.MarshalIndent(allHrvData, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal HRV data to JSON: %w", err)
|
||||
}
|
||||
@@ -342,23 +300,28 @@ func runHrv(cmd *cobra.Command, args []string) error {
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "HRV Value"})
|
||||
for _, data := range hrvData {
|
||||
writer.Write([]string{"Date", "WeeklyAvg", "LastNightAvg", "Status", "Feedback"})
|
||||
for _, data := range allHrvData {
|
||||
writer.Write([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", data.HrvValue),
|
||||
data.CalendarDate.Format("2006-01-02"),
|
||||
func() string { if data.WeeklyAvg != nil { return fmt.Sprintf("%.2f", *data.WeeklyAvg) } ; return "N/A" }(),
|
||||
func() string { if data.LastNightAvg != nil { return fmt.Sprintf("%.2f", *data.LastNightAvg) } ; return "N/A" }(),
|
||||
data.Status,
|
||||
data.FeedbackPhrase,
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "HRV Value"})
|
||||
for _, data := range hrvData {
|
||||
table.Append([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", data.HrvValue),
|
||||
})
|
||||
tbl := table.New("Date", "Weekly Avg", "Last Night Avg", "Status", "Feedback")
|
||||
for _, data := range allHrvData {
|
||||
tbl.AddRow(
|
||||
data.CalendarDate.Format("2006-01-02"),
|
||||
func() string { if data.WeeklyAvg != nil { return fmt.Sprintf("%.2f", *data.WeeklyAvg) } ; return "N/A" }(),
|
||||
func() string { if data.LastNightAvg != nil { return fmt.Sprintf("%.2f", *data.LastNightAvg) } ; return "N/A" }(),
|
||||
data.Status,
|
||||
data.FeedbackPhrase,
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -435,7 +398,7 @@ func runStress(cmd *cobra.Command, args []string) error {
|
||||
stressData = []types.StressData{}
|
||||
for key, entry := range aggregatedStress {
|
||||
stressData = append(stressData, types.StressData{
|
||||
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||
Date: types.ParseAggregationKey(key, healthAggregate),
|
||||
StressLevel: entry.StressLevel / entry.Count,
|
||||
RestStressLevel: entry.RestStressLevel / entry.Count,
|
||||
})
|
||||
@@ -464,16 +427,15 @@ func runStress(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "Stress Level", "Rest Stress Level"})
|
||||
tbl := table.New("Date", "Stress Level", "Rest Stress Level")
|
||||
for _, data := range stressData {
|
||||
table.Append([]string{
|
||||
tbl.AddRow(
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.StressLevel),
|
||||
fmt.Sprintf("%d", data.RestStressLevel),
|
||||
})
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -482,82 +444,33 @@ func runStress(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runBodyBattery(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
sessionFile := "garmin_session.json" // TODO: Make session file configurable
|
||||
if err := garminClient.LoadSession(sessionFile); err != nil {
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
var startDate, endDate time.Time
|
||||
var targetDate time.Time
|
||||
if healthYesterday {
|
||||
startDate = time.Now().AddDate(0, 0, -1)
|
||||
endDate = startDate
|
||||
targetDate = time.Now().AddDate(0, 0, -1)
|
||||
} else {
|
||||
// Default to today if no specific range or yesterday is given
|
||||
startDate = time.Now()
|
||||
endDate = time.Now()
|
||||
targetDate = time.Now()
|
||||
}
|
||||
|
||||
bodyBatteryData, err := garminClient.GetBodyBatteryData(startDate, endDate)
|
||||
bodyBatteryData, err := garminClient.GetDetailedBodyBatteryData(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get Body Battery data: %w", err)
|
||||
}
|
||||
|
||||
if len(bodyBatteryData) == 0 {
|
||||
if bodyBatteryData == nil {
|
||||
fmt.Println("No Body Battery data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply aggregation if requested
|
||||
if healthAggregate != "" {
|
||||
aggregatedBodyBattery := make(map[string]struct {
|
||||
BatteryLevel int
|
||||
Charge int
|
||||
Drain int
|
||||
Count int
|
||||
})
|
||||
|
||||
for _, data := range bodyBatteryData {
|
||||
key := ""
|
||||
switch healthAggregate {
|
||||
case "day":
|
||||
key = data.Date.Format("2006-01-02")
|
||||
case "week":
|
||||
year, week := data.Date.ISOWeek()
|
||||
key = fmt.Sprintf("%d-W%02d", year, week)
|
||||
case "month":
|
||||
key = data.Date.Format("2006-01")
|
||||
case "year":
|
||||
key = data.Date.Format("2006")
|
||||
default:
|
||||
return fmt.Errorf("unsupported aggregation period: %s", healthAggregate)
|
||||
}
|
||||
|
||||
entry := aggregatedBodyBattery[key]
|
||||
entry.BatteryLevel += data.BatteryLevel
|
||||
entry.Charge += data.Charge
|
||||
entry.Drain += data.Drain
|
||||
entry.Count++
|
||||
aggregatedBodyBattery[key] = entry
|
||||
}
|
||||
|
||||
// Convert aggregated data back to a slice for output
|
||||
bodyBatteryData = []types.BodyBatteryData{}
|
||||
for key, entry := range aggregatedBodyBattery {
|
||||
bodyBatteryData = append(bodyBatteryData, types.BodyBatteryData{
|
||||
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||
BatteryLevel: entry.BatteryLevel / entry.Count,
|
||||
Charge: entry.Charge / entry.Count,
|
||||
Drain: entry.Drain / entry.Count,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output")
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
@@ -570,27 +483,24 @@ func runBodyBattery(cmd *cobra.Command, args []string) error {
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "BatteryLevel", "Charge", "Drain"})
|
||||
for _, data := range bodyBatteryData {
|
||||
writer.Write([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.BatteryLevel),
|
||||
fmt.Sprintf("%d", data.Charge),
|
||||
fmt.Sprintf("%d", data.Drain),
|
||||
})
|
||||
}
|
||||
writer.Write([]string{"Date", "CurrentLevel", "DayChange", "MaxStressLevel", "AvgStressLevel"})
|
||||
writer.Write([]string{
|
||||
bodyBatteryData.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", bodyBatteryData.GetCurrentLevel()),
|
||||
fmt.Sprintf("%d", bodyBatteryData.GetDayChange()),
|
||||
fmt.Sprintf("%d", bodyBatteryData.MaxStressLevel),
|
||||
fmt.Sprintf("%d", bodyBatteryData.AvgStressLevel),
|
||||
})
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "Battery Level", "Charge", "Drain"})
|
||||
for _, data := range bodyBatteryData {
|
||||
table.Append([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.BatteryLevel),
|
||||
fmt.Sprintf("%d", data.Charge),
|
||||
fmt.Sprintf("%d", data.Drain),
|
||||
})
|
||||
}
|
||||
table.Render()
|
||||
tbl := table.New("Date", "Current Level", "Day Change", "Max Stress", "Avg Stress")
|
||||
tbl.AddRow(
|
||||
bodyBatteryData.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", bodyBatteryData.GetCurrentLevel()),
|
||||
fmt.Sprintf("%d", bodyBatteryData.GetDayChange()),
|
||||
fmt.Sprintf("%d", bodyBatteryData.MaxStressLevel),
|
||||
fmt.Sprintf("%d", bodyBatteryData.AvgStressLevel),
|
||||
)
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -599,93 +509,30 @@ func runBodyBattery(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runVO2Max(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||
client, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
sessionFile := "garmin_session.json" // TODO: Make session file configurable
|
||||
if err := garminClient.LoadSession(sessionFile); err != nil {
|
||||
if err := client.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
var startDate, endDate time.Time
|
||||
|
||||
if healthDateFrom != "" {
|
||||
startDate, err = time.Parse("2006-01-02", healthDateFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format for --from: %w", err)
|
||||
}
|
||||
} else {
|
||||
startDate = time.Now().AddDate(0, 0, -7) // Default to last 7 days
|
||||
}
|
||||
|
||||
if healthDateTo != "" {
|
||||
endDate = time.Now() // Default to today
|
||||
parsedDate, err := time.Parse("2006-01-02", healthDateTo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format for --to: %w", err)
|
||||
}
|
||||
endDate = parsedDate
|
||||
}
|
||||
|
||||
vo2maxData, err := garminClient.GetVO2MaxData(startDate, endDate)
|
||||
profile, err := client.InternalClient().GetCurrentVO2Max()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get VO2 Max data: %w", err)
|
||||
}
|
||||
|
||||
if len(vo2maxData) == 0 {
|
||||
if profile.Running == nil && profile.Cycling == nil {
|
||||
fmt.Println("No VO2 Max data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply aggregation if requested
|
||||
if healthAggregate != "" {
|
||||
aggregatedVO2Max := make(map[string]struct {
|
||||
VO2MaxRunning float64
|
||||
VO2MaxCycling float64
|
||||
Count int
|
||||
})
|
||||
|
||||
for _, data := range vo2maxData {
|
||||
key := ""
|
||||
switch healthAggregate {
|
||||
case "day":
|
||||
key = data.Date.Format("2006-01-02")
|
||||
case "week":
|
||||
year, week := data.Date.ISOWeek()
|
||||
key = fmt.Sprintf("%d-W%02d", year, week)
|
||||
case "month":
|
||||
key = data.Date.Format("2006-01")
|
||||
case "year":
|
||||
key = data.Date.Format("2006")
|
||||
default:
|
||||
return fmt.Errorf("unsupported aggregation period: %s", healthAggregate)
|
||||
}
|
||||
|
||||
entry := aggregatedVO2Max[key]
|
||||
entry.VO2MaxRunning += data.VO2MaxRunning
|
||||
entry.VO2MaxCycling += data.VO2MaxCycling
|
||||
entry.Count++
|
||||
aggregatedVO2Max[key] = entry
|
||||
}
|
||||
|
||||
// Convert aggregated data back to a slice for output
|
||||
vo2maxData = []types.VO2MaxData{}
|
||||
for key, entry := range aggregatedVO2Max {
|
||||
vo2maxData = append(vo2maxData, types.VO2MaxData{
|
||||
Date: utils.ParseAggregationKey(key, healthAggregate),
|
||||
VO2MaxRunning: entry.VO2MaxRunning / float64(entry.Count),
|
||||
VO2MaxCycling: entry.VO2MaxCycling / float64(entry.Count),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output")
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(vo2maxData, "", " ")
|
||||
data, err := json.MarshalIndent(profile, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal VO2 Max data to JSON: %w", err)
|
||||
}
|
||||
@@ -694,25 +541,43 @@ func runVO2Max(cmd *cobra.Command, args []string) error {
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "VO2MaxRunning", "VO2MaxCycling"})
|
||||
for _, data := range vo2maxData {
|
||||
writer.Write([]string{"Type", "Value", "Date", "Source"})
|
||||
if profile.Running != nil {
|
||||
writer.Write([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", data.VO2MaxRunning),
|
||||
fmt.Sprintf("%.2f", data.VO2MaxCycling),
|
||||
profile.Running.ActivityType,
|
||||
fmt.Sprintf("%.2f", profile.Running.Value),
|
||||
profile.Running.Date.Format("2006-01-02"),
|
||||
profile.Running.Source,
|
||||
})
|
||||
}
|
||||
if profile.Cycling != nil {
|
||||
writer.Write([]string{
|
||||
profile.Cycling.ActivityType,
|
||||
fmt.Sprintf("%.2f", profile.Cycling.Value),
|
||||
profile.Cycling.Date.Format("2006-01-02"),
|
||||
profile.Cycling.Source,
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "VO2 Max Running", "VO2 Max Cycling"})
|
||||
for _, data := range vo2maxData {
|
||||
table.Append([]string{
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", data.VO2MaxRunning),
|
||||
fmt.Sprintf("%.2f", data.VO2MaxCycling),
|
||||
})
|
||||
tbl := table.New("Type", "Value", "Date", "Source")
|
||||
|
||||
if profile.Running != nil {
|
||||
tbl.AddRow(
|
||||
profile.Running.ActivityType,
|
||||
fmt.Sprintf("%.2f", profile.Running.Value),
|
||||
profile.Running.Date.Format("2006-01-02"),
|
||||
profile.Running.Source,
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
if profile.Cycling != nil {
|
||||
tbl.AddRow(
|
||||
profile.Cycling.ActivityType,
|
||||
fmt.Sprintf("%.2f", profile.Cycling.Value),
|
||||
profile.Cycling.Date.Format("2006-01-02"),
|
||||
profile.Cycling.Source,
|
||||
)
|
||||
}
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -721,13 +586,12 @@ func runVO2Max(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runHRZones(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
sessionFile := "garmin_session.json" // TODO: Make session file configurable
|
||||
if err := garminClient.LoadSession(sessionFile); err != nil {
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
@@ -764,29 +628,27 @@ func runHRZones(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Resting HR", "Max HR", "Lactate Threshold", "Updated At"})
|
||||
table.Append([]string{
|
||||
tbl := table.New("Resting HR", "Max HR", "Lactate Threshold", "Updated At")
|
||||
tbl.AddRow(
|
||||
strconv.Itoa(hrZonesData.RestingHR),
|
||||
strconv.Itoa(hrZonesData.MaxHR),
|
||||
strconv.Itoa(hrZonesData.LactateThreshold),
|
||||
hrZonesData.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
})
|
||||
table.Render()
|
||||
)
|
||||
tbl.Print()
|
||||
|
||||
fmt.Println()
|
||||
|
||||
zonesTable := tablewriter.NewWriter(os.Stdout)
|
||||
zonesTable.Header([]string{"Zone", "Min BPM", "Max BPM", "Name"})
|
||||
zonesTable := table.New("Zone", "Min BPM", "Max BPM", "Name")
|
||||
for _, zone := range hrZonesData.Zones {
|
||||
zonesTable.Append([]string{
|
||||
zonesTable.AddRow(
|
||||
strconv.Itoa(zone.Zone),
|
||||
strconv.Itoa(zone.MinBPM),
|
||||
strconv.Itoa(zone.MaxBPM),
|
||||
zone.Name,
|
||||
})
|
||||
)
|
||||
}
|
||||
zonesTable.Render()
|
||||
zonesTable.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -799,3 +661,189 @@ var wellnessCmd *cobra.Command
|
||||
func runWellness(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func runTrainingStatus(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
var targetDate time.Time
|
||||
if healthDateFrom != "" {
|
||||
targetDate, err = time.Parse("2006-01-02", healthDateFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format for --from: %w", err)
|
||||
}
|
||||
} else {
|
||||
targetDate = time.Now()
|
||||
}
|
||||
|
||||
trainingStatus, err := garminClient.GetTrainingStatus(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if trainingStatus == nil {
|
||||
fmt.Println("No training status data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(trainingStatus, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal training status to JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
case "csv":
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "Status", "LoadRatio"})
|
||||
writer.Write([]string{
|
||||
trainingStatus.CalendarDate.Format("2006-01-02"),
|
||||
trainingStatus.TrainingStatusKey,
|
||||
fmt.Sprintf("%.2f", trainingStatus.LoadRatio),
|
||||
})
|
||||
case "table":
|
||||
tbl := table.New("Date", "Status", "Load Ratio")
|
||||
tbl.AddRow(
|
||||
trainingStatus.CalendarDate.Format("2006-01-02"),
|
||||
trainingStatus.TrainingStatusKey,
|
||||
fmt.Sprintf("%.2f", trainingStatus.LoadRatio),
|
||||
)
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runTrainingLoad(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
var targetDate time.Time
|
||||
if healthDateFrom != "" {
|
||||
targetDate, err = time.Parse("2006-01-02", healthDateFrom)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format for --from: %w", err)
|
||||
}
|
||||
} else {
|
||||
targetDate = time.Now()
|
||||
}
|
||||
|
||||
trainingLoad, err := garminClient.GetTrainingLoad(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if trainingLoad == nil {
|
||||
fmt.Println("No training load data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(trainingLoad, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal training load to JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
case "csv":
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"Date", "AcuteLoad", "ChronicLoad", "LoadRatio"})
|
||||
writer.Write([]string{
|
||||
trainingLoad.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", trainingLoad.AcuteTrainingLoad),
|
||||
fmt.Sprintf("%.2f", trainingLoad.ChronicTrainingLoad),
|
||||
fmt.Sprintf("%.2f", trainingLoad.TrainingLoadRatio),
|
||||
})
|
||||
case "table":
|
||||
tbl := table.New("Date", "Acute Load", "Chronic Load", "Load Ratio")
|
||||
tbl.AddRow(
|
||||
trainingLoad.CalendarDate.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", trainingLoad.AcuteTrainingLoad),
|
||||
fmt.Sprintf("%.2f", trainingLoad.ChronicTrainingLoad),
|
||||
fmt.Sprintf("%.2f", trainingLoad.TrainingLoadRatio),
|
||||
)
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runFitnessAge(cmd *cobra.Command, args []string) error {
|
||||
garminClient, err := garmin.NewClient(viper.GetString("domain"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create client: %w", err)
|
||||
}
|
||||
|
||||
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
|
||||
return fmt.Errorf("not logged in: %w", err)
|
||||
}
|
||||
|
||||
fitnessAge, err := garminClient.GetFitnessAge()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get fitness age: %w", err)
|
||||
}
|
||||
|
||||
if fitnessAge == nil {
|
||||
fmt.Println("No fitness age data found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
outputFormat := viper.GetString("output.format")
|
||||
|
||||
switch outputFormat {
|
||||
case "json":
|
||||
data, err := json.MarshalIndent(fitnessAge, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal fitness age to JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(data))
|
||||
case "csv":
|
||||
writer := csv.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
writer.Write([]string{"FitnessAge", "ChronologicalAge", "VO2MaxRunning", "LastUpdated"})
|
||||
writer.Write([]string{
|
||||
fmt.Sprintf("%d", fitnessAge.FitnessAge),
|
||||
fmt.Sprintf("%d", fitnessAge.ChronologicalAge),
|
||||
fmt.Sprintf("%.2f", fitnessAge.VO2MaxRunning),
|
||||
fitnessAge.LastUpdated.Format("2006-01-02"),
|
||||
})
|
||||
case "table":
|
||||
tbl := table.New("Fitness Age", "Chronological Age", "VO2 Max Running", "Last Updated")
|
||||
tbl.AddRow(
|
||||
fmt.Sprintf("%d", fitnessAge.FitnessAge),
|
||||
fmt.Sprintf("%d", fitnessAge.ChronologicalAge),
|
||||
fmt.Sprintf("%.2f", fitnessAge.VO2MaxRunning),
|
||||
fitnessAge.LastUpdated.Format("2006-01-02"),
|
||||
)
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,12 +7,11 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/olekukonko/tablewriter"
|
||||
"github.com/rodaine/table"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
types "go-garth/internal/types"
|
||||
"go-garth/internal/utils"
|
||||
types "go-garth/internal/models/types"
|
||||
"go-garth/pkg/garmin"
|
||||
)
|
||||
|
||||
@@ -87,7 +86,7 @@ func runDistance(cmd *cobra.Command, args []string) error {
|
||||
distanceData = []types.DistanceData{}
|
||||
for key, entry := range aggregatedDistance {
|
||||
distanceData = append(distanceData, types.DistanceData{
|
||||
Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||
Date: types.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||
Distance: entry.Distance / float64(entry.Count),
|
||||
})
|
||||
}
|
||||
@@ -114,15 +113,14 @@ func runDistance(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "Distance (km)"})
|
||||
tbl := table.New("Date", "Distance (km)")
|
||||
for _, data := range distanceData {
|
||||
table.Append([]string{
|
||||
tbl.AddRow(
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%.2f", data.Distance/1000),
|
||||
})
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
@@ -197,7 +195,7 @@ func runCalories(cmd *cobra.Command, args []string) error {
|
||||
caloriesData = []types.CaloriesData{}
|
||||
for key, entry := range aggregatedCalories {
|
||||
caloriesData = append(caloriesData, types.CaloriesData{
|
||||
Date: utils.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||
Date: types.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
|
||||
Calories: entry.Calories / entry.Count,
|
||||
})
|
||||
}
|
||||
@@ -224,15 +222,14 @@ func runCalories(cmd *cobra.Command, args []string) error {
|
||||
})
|
||||
}
|
||||
case "table":
|
||||
table := tablewriter.NewWriter(os.Stdout)
|
||||
table.Header([]string{"Date", "Calories"})
|
||||
tbl := table.New("Date", "Calories")
|
||||
for _, data := range caloriesData {
|
||||
table.Append([]string{
|
||||
tbl.AddRow(
|
||||
data.Date.Format("2006-01-02"),
|
||||
fmt.Sprintf("%d", data.Calories),
|
||||
})
|
||||
)
|
||||
}
|
||||
table.Render()
|
||||
tbl.Print()
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", outputFormat)
|
||||
}
|
||||
|
||||
28
e2e_test.sh
Executable file
28
e2e_test.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "--- Running End-to-End CLI Tests ---"
|
||||
|
||||
echo "Testing garth --help"
|
||||
go run go-garth/cmd/garth --help
|
||||
|
||||
echo "Testing garth auth status"
|
||||
go run go-garth/cmd/garth auth status
|
||||
|
||||
echo "Testing garth activities list"
|
||||
go run go-garth/cmd/garth activities list --limit 5
|
||||
|
||||
echo "Testing garth health sleep"
|
||||
go run go-garth/cmd/garth health sleep --from 2024-01-01 --to 2024-01-02
|
||||
|
||||
echo "Testing garth stats distance"
|
||||
go run go-garth/cmd/garth stats distance --year
|
||||
|
||||
echo "Testing garth health vo2max"
|
||||
go run go-garth/cmd/garth health vo2max --from 2024-01-01 --to 2024-01-02
|
||||
|
||||
echo "Testing garth health hr-zones"
|
||||
go run go-garth/cmd/garth health hr-zones
|
||||
|
||||
echo "--- End-to-End CLI Tests Passed ---"
|
||||
629
endpoints.md
Normal file
629
endpoints.md
Normal file
@@ -0,0 +1,629 @@
|
||||
# High Priority Endpoints Implementation Guide
|
||||
|
||||
## Overview
|
||||
This guide covers implementing the most commonly requested Garmin Connect API endpoints that are currently missing from your codebase. We'll focus on the high-priority endpoints that provide detailed health and fitness data.
|
||||
|
||||
## 1. Detailed Sleep Data Implementation
|
||||
|
||||
### Files to Create/Modify
|
||||
|
||||
#### A. Create `internal/data/sleep_detailed.go`
|
||||
```go
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/types"
|
||||
)
|
||||
|
||||
// SleepLevel represents different sleep stages
|
||||
type SleepLevel struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
SleepLevel string `json:"sleepLevel"` // "deep", "light", "rem", "awake"
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepScore represents detailed sleep scoring
|
||||
type SleepScore struct {
|
||||
Overall int `json:"overall"`
|
||||
Composition SleepScoreBreakdown `json:"composition"`
|
||||
Revitalization SleepScoreBreakdown `json:"revitalization"`
|
||||
Duration SleepScoreBreakdown `json:"duration"`
|
||||
DeepPercentage float64 `json:"deepPercentage"`
|
||||
LightPercentage float64 `json:"lightPercentage"`
|
||||
RemPercentage float64 `json:"remPercentage"`
|
||||
RestfulnessValue float64 `json:"restfulnessValue"`
|
||||
}
|
||||
|
||||
type SleepScoreBreakdown struct {
|
||||
QualifierKey string `json:"qualifierKey"`
|
||||
OptimalStart float64 `json:"optimalStart"`
|
||||
OptimalEnd float64 `json:"optimalEnd"`
|
||||
Value float64 `json:"value"`
|
||||
IdealStartSecs *int `json:"idealStartInSeconds"`
|
||||
IdealEndSecs *int `json:"idealEndInSeconds"`
|
||||
}
|
||||
|
||||
// DetailedSleepData represents comprehensive sleep data
|
||||
type DetailedSleepData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepStartTimestampLocal time.Time `json:"sleepStartTimestampLocal"`
|
||||
SleepEndTimestampLocal time.Time `json:"sleepEndTimestampLocal"`
|
||||
UnmeasurableSleepSeconds int `json:"unmeasurableSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
DeviceRemCapable bool `json:"deviceRemCapable"`
|
||||
SleepLevels []SleepLevel `json:"sleepLevels"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepScores *SleepScore `json:"sleepScores"`
|
||||
AverageSpO2Value *float64 `json:"averageSpO2Value"`
|
||||
LowestSpO2Value *int `json:"lowestSpO2Value"`
|
||||
HighestSpO2Value *int `json:"highestSpO2Value"`
|
||||
AverageRespirationValue *float64 `json:"averageRespirationValue"`
|
||||
LowestRespirationValue *float64 `json:"lowestRespirationValue"`
|
||||
HighestRespirationValue *float64 `json:"highestRespirationValue"`
|
||||
AvgSleepStress *float64 `json:"avgSleepStress"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
// NewDetailedSleepData creates a new DetailedSleepData instance
|
||||
func NewDetailedSleepData() *DetailedSleepData {
|
||||
sleep := &DetailedSleepData{}
|
||||
sleep.GetFunc = sleep.get
|
||||
return sleep
|
||||
}
|
||||
|
||||
func (d *DetailedSleepData) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
client.Username, dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||
func (d *DetailedSleepData) GetSleepEfficiency() float64 {
|
||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
||||
if totalTime == 0 {
|
||||
return 0
|
||||
}
|
||||
return (sleepTime / totalTime) * 100
|
||||
}
|
||||
|
||||
// GetTotalSleepTime returns total sleep time in hours
|
||||
func (d *DetailedSleepData) GetTotalSleepTime() float64 {
|
||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
||||
return float64(totalSeconds) / 3600.0
|
||||
}
|
||||
```
|
||||
|
||||
#### B. Add methods to `internal/api/client/client.go`
|
||||
```go
|
||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
sleepData := data.NewDetailedSleepData()
|
||||
result, err := sleepData.Get(date, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
detailedSleep, ok := result.(*types.DetailedSleepData)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected sleep data type")
|
||||
}
|
||||
|
||||
return detailedSleep, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 2. Heart Rate Variability (HRV) Implementation
|
||||
|
||||
#### A. Update `internal/data/hrv.go` (extend existing)
|
||||
Add these methods to your existing HRV implementation:
|
||||
|
||||
```go
|
||||
// HRVStatus represents HRV status and baseline
|
||||
type HRVStatus struct {
|
||||
Status string `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
BaselineLowUpper int `json:"baselineLowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// DailyHRVData represents comprehensive daily HRV data
|
||||
type DailyHRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
WeeklyAvg *float64 `json:"weeklyAvg"`
|
||||
LastNightAvg *float64 `json:"lastNightAvg"`
|
||||
LastNight5MinHigh *float64 `json:"lastNight5MinHigh"`
|
||||
Baseline HRVBaseline `json:"baseline"`
|
||||
Status string `json:"status"`
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
CreateTimeStamp time.Time `json:"createTimeStamp"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
type HRVBaseline struct {
|
||||
LowUpper int `json:"lowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// Update the existing get method in hrv.go
|
||||
func (h *DailyHRVData) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
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, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Body Battery Detailed Implementation
|
||||
|
||||
#### A. Update `internal/data/body_battery.go`
|
||||
Add these structures and methods:
|
||||
|
||||
```go
|
||||
// BodyBatteryEvent represents events that impact Body Battery
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"` // "sleep", "activity", "stress"
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||
type DetailedBodyBatteryData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
BodyBatteryValuesArray [][]interface{} `json:"bodyBatteryValuesArray"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
Events []BodyBatteryEvent `json:"bodyBatteryEvents"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
func NewDetailedBodyBatteryData() *DetailedBodyBatteryData {
|
||||
bb := &DetailedBodyBatteryData{}
|
||||
bb.GetFunc = bb.get
|
||||
return bb
|
||||
}
|
||||
|
||||
func (d *DetailedBodyBatteryData) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := client.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := client.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetCurrentLevel returns the most recent Body Battery level
|
||||
func (d *DetailedBodyBatteryData) GetCurrentLevel() int {
|
||||
if len(d.BodyBatteryValuesArray) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level
|
||||
}
|
||||
|
||||
// GetDayChange returns the Body Battery change for the day
|
||||
func (d *DetailedBodyBatteryData) GetDayChange() int {
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level - readings[0].Level
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Training Status & Load Implementation
|
||||
|
||||
#### A. Create `internal/data/training.go`
|
||||
```go
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
)
|
||||
|
||||
// TrainingStatus represents current training status
|
||||
type TrainingStatus struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
TrainingStatusKey string `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
|
||||
TrainingStatusTypeKey string `json:"trainingStatusTypeKey"`
|
||||
TrainingStatusValue int `json:"trainingStatusValue"`
|
||||
LoadRatio float64 `json:"loadRatio"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
// TrainingLoad represents training load data
|
||||
type TrainingLoad struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
AcuteTrainingLoad float64 `json:"acuteTrainingLoad"`
|
||||
ChronicTrainingLoad float64 `json:"chronicTrainingLoad"`
|
||||
TrainingLoadRatio float64 `json:"trainingLoadRatio"`
|
||||
TrainingEffectAerobic float64 `json:"trainingEffectAerobic"`
|
||||
TrainingEffectAnaerobic float64 `json:"trainingEffectAnaerobic"`
|
||||
BaseData
|
||||
}
|
||||
|
||||
// FitnessAge represents fitness age calculation
|
||||
type FitnessAge struct {
|
||||
FitnessAge int `json:"fitnessAge"`
|
||||
ChronologicalAge int `json:"chronologicalAge"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
func NewTrainingStatus() *TrainingStatus {
|
||||
ts := &TrainingStatus{}
|
||||
ts.GetFunc = ts.get
|
||||
return ts
|
||||
}
|
||||
|
||||
func (t *TrainingStatus) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func NewTrainingLoad() *TrainingLoad {
|
||||
tl := &TrainingLoad{}
|
||||
tl.GetFunc = tl.get
|
||||
return tl
|
||||
}
|
||||
|
||||
func (t *TrainingLoad) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
endDate := day.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Client Methods Integration
|
||||
|
||||
#### Add these methods to `internal/api/client/client.go`:
|
||||
|
||||
```go
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
trainingStatus := data.NewTrainingStatus()
|
||||
result, err := trainingStatus.Get(date, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
status, ok := result.(*types.TrainingStatus)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected training status type")
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
trainingLoad := data.NewTrainingLoad()
|
||||
result, err := trainingLoad.Get(date, c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
load, ok := result.(*types.TrainingLoad)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected training load type")
|
||||
}
|
||||
|
||||
return load, nil
|
||||
}
|
||||
|
||||
// GetFitnessAge retrieves fitness age calculation
|
||||
func (c *Client) GetFitnessAge() (*types.FitnessAge, error) {
|
||||
path := "/fitness-service/fitness/fitnessAge"
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get fitness age: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var fitnessAge types.FitnessAge
|
||||
if err := json.Unmarshal(data, &fitnessAge); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse fitness age: %w", err)
|
||||
}
|
||||
|
||||
fitnessAge.LastUpdated = time.Now()
|
||||
return &fitnessAge, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Sleep Data (Week 1)
|
||||
1. Create `internal/data/sleep_detailed.go`
|
||||
2. Update `internal/types/garmin.go` with sleep types
|
||||
3. Add client methods
|
||||
4. Create tests
|
||||
5. Test with real data
|
||||
|
||||
### Phase 2: HRV Enhancement (Week 2)
|
||||
1. Update existing `internal/data/hrv.go`
|
||||
2. Add new HRV types to types file
|
||||
3. Enhance client methods
|
||||
4. Create comprehensive tests
|
||||
|
||||
### Phase 3: Body Battery Details (Week 3)
|
||||
1. Update `internal/data/body_battery.go`
|
||||
2. Add event tracking
|
||||
3. Add convenience methods
|
||||
4. Create tests
|
||||
|
||||
### Phase 4: Training Metrics (Week 4)
|
||||
1. Create `internal/data/training.go`
|
||||
2. Add training types
|
||||
3. Implement client methods
|
||||
4. Create tests and validation
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Create test files for each new data type:
|
||||
|
||||
```go
|
||||
// Example test structure
|
||||
func TestDetailedSleepData_Get(t *testing.T) {
|
||||
// Mock response from API
|
||||
mockResponse := `{
|
||||
"dailySleepDTO": {
|
||||
"userProfilePk": 12345,
|
||||
"calendarDate": "2023-06-15",
|
||||
"deepSleepSeconds": 7200,
|
||||
"lightSleepSeconds": 14400,
|
||||
"remSleepSeconds": 3600,
|
||||
"awakeSleepSeconds": 1800
|
||||
},
|
||||
"sleepMovement": [],
|
||||
"sleepLevels": []
|
||||
}`
|
||||
|
||||
// Create mock client
|
||||
server := testutils.MockJSONResponse(http.StatusOK, mockResponse)
|
||||
defer server.Close()
|
||||
|
||||
// Test implementation
|
||||
// ... test logic
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
For each endpoint, implement consistent error handling:
|
||||
|
||||
```go
|
||||
func (d *DataType) get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Log the error but don't fail completely
|
||||
fmt.Printf("Warning: Failed to get %s data: %v\n", "datatype", err)
|
||||
return nil, nil // Return nil data, not error for missing data
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil // No data available
|
||||
}
|
||||
|
||||
// Parse and validate
|
||||
var result DataType
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s data: %w", "datatype", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
After implementation, users can access the data like this:
|
||||
|
||||
```go
|
||||
// Get detailed sleep data
|
||||
sleepData, err := client.GetDetailedSleepData(time.Now().AddDate(0, 0, -1))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if sleepData != nil {
|
||||
fmt.Printf("Sleep efficiency: %.1f%%\n", sleepData.GetSleepEfficiency())
|
||||
fmt.Printf("Total sleep: %.1f hours\n", sleepData.GetTotalSleepTime())
|
||||
}
|
||||
|
||||
// Get training status
|
||||
status, err := client.GetTrainingStatus(time.Now())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if status != nil {
|
||||
fmt.Printf("Training Status: %s\n", status.TrainingStatusKey)
|
||||
fmt.Printf("Load Ratio: %.2f\n", status.LoadRatio)
|
||||
}
|
||||
```
|
||||
|
||||
This implementation guide provides a comprehensive foundation for adding the most requested Garmin Connect API endpoints to your Go client.
|
||||
9
go.mod
9
go.mod
@@ -4,7 +4,7 @@ go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/olekukonko/tablewriter v1.0.9
|
||||
github.com/rodaine/table v1.3.0
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/viper v1.21.0
|
||||
@@ -12,17 +12,10 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
github.com/olekukonko/ll v0.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
|
||||
27
go.sum
27
go.sum
@@ -1,10 +1,9 @@
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
@@ -21,22 +20,10 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
|
||||
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
|
||||
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
|
||||
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
|
||||
github.com/olekukonko/ll v0.1.1 h1:9Dfeed5/Mgaxb9lHRAftLK9pVfYETvHn+If6lywVhJc=
|
||||
github.com/olekukonko/ll v0.1.1/go.mod h1:2dJo+hYZcJMLMbKwHEWvxCUbAOLc/CXWS9noET22Mdo=
|
||||
github.com/olekukonko/tablewriter v1.0.9 h1:XGwRsYLC2bY7bNd93Dk51bcPZksWZmLYuaTHR0FqfL8=
|
||||
github.com/olekukonko/tablewriter v1.0.9/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -44,6 +31,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rodaine/table v1.3.0 h1:4/3S3SVkHnVZX91EHFvAMV7K42AnJ0XuymRR2C5HlGE=
|
||||
github.com/rodaine/table v1.3.0/go.mod h1:47zRsHar4zw0jgxGxL9YtFfs7EGN6B/TaS+/Dmk4WxU=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
@@ -64,13 +53,20 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
@@ -80,5 +76,6 @@ golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/errors"
|
||||
"go-garth/internal/auth/sso"
|
||||
"go-garth/internal/types"
|
||||
"go-garth/internal/errors"
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// Client represents the Garmin Connect API client
|
||||
@@ -29,6 +30,80 @@ type Client struct {
|
||||
OAuth2Token *types.OAuth2Token
|
||||
}
|
||||
|
||||
// Verify that Client implements shared.APIClient
|
||||
var _ shared.APIClient = (*Client)(nil)
|
||||
|
||||
// GetUsername returns the authenticated username
|
||||
func (c *Client) GetUsername() string {
|
||||
return c.Username
|
||||
}
|
||||
|
||||
// GetUserSettings retrieves the current user's settings
|
||||
func (c *Client) GetUserSettings() (*types.UserSettings, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
settingsURL := fmt.Sprintf("%s://%s/userprofile-service/userprofile/user-settings", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create user settings request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user settings",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "User settings request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var settings types.UserSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse user settings",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
if domain == "" {
|
||||
@@ -145,7 +220,11 @@ func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/socialProfile", scheme, c.Domain)
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
if err != nil {
|
||||
@@ -464,71 +543,71 @@ func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.Calories
|
||||
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
||||
}
|
||||
|
||||
|
||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
|
||||
vo2MaxURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/vo2max?%s", scheme, c.Domain, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", vo2MaxURL, nil)
|
||||
// Get user settings which contains current VO2 max values
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create VO2 max request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile")
|
||||
// Create VO2MaxData for the date range
|
||||
var results []types.VO2MaxData
|
||||
current := startDate
|
||||
for !current.After(endDate) {
|
||||
vo2Data := types.VO2MaxData{
|
||||
Date: current,
|
||||
UserProfilePK: settings.ID,
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
// Set VO2 max values if available
|
||||
if settings.UserData.VO2MaxRunning != nil {
|
||||
vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning
|
||||
}
|
||||
if settings.UserData.VO2MaxCycling != nil {
|
||||
vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling
|
||||
}
|
||||
|
||||
results = append(results, vo2Data)
|
||||
current = current.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
||||
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get VO2 max data",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "VO2 max request failed",
|
||||
},
|
||||
},
|
||||
profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
var vo2MaxData []types.VO2MaxData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&vo2MaxData); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse VO2 max data",
|
||||
Cause: err,
|
||||
},
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
return vo2MaxData, nil
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetHeartRateZones retrieves heart rate zone data
|
||||
@@ -691,6 +770,163 @@ func (c *Client) SaveSession(filename string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
||||
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
|
||||
// LoadSession loads a session from a file
|
||||
func (c *Client) LoadSession(filename string) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
@@ -724,4 +960,4 @@ func (c *Client) LoadSession(filename string) error {
|
||||
func (c *Client) RefreshSession() error {
|
||||
// TODO: Implement token refresh logic
|
||||
return fmt.Errorf("RefreshSession not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ func TestClient_GetUserProfile(t *testing.T) {
|
||||
u, _ := url.Parse(server.URL)
|
||||
c, err := client.NewClient(u.Host)
|
||||
require.NoError(t, err)
|
||||
c.Domain = u.Host
|
||||
require.NoError(t, err)
|
||||
c.HTTPClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
|
||||
18
internal/api/client_interface.go
Normal file
18
internal/api/client_interface.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
"go-garth/internal/users"
|
||||
)
|
||||
|
||||
// APIClient defines the interface for making API calls
|
||||
type APIClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
GetUserSettings() (*users.UserSettings, error)
|
||||
GetUserProfile() (*types.UserProfile, error)
|
||||
GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error)
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/types"
|
||||
"go-garth/internal/models/types"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
@@ -159,4 +159,4 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
}
|
||||
|
||||
return &oauth2Token, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-garth/internal/auth/oauth"
|
||||
"go-garth/internal/types"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -6,82 +6,31 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||
type DailyBodyBatteryStress struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
StressChartValueOffset int `json:"stressChartValueOffset"`
|
||||
StressChartYAxisOrigin int `json:"stressChartYAxisOrigin"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
BodyBatteryValuesArray [][]any `json:"bodyBatteryValuesArray"`
|
||||
}
|
||||
|
||||
// BodyBatteryEvent represents a Body Battery impact event
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"`
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// BodyBatteryData represents legacy Body Battery events data
|
||||
type BodyBatteryData struct {
|
||||
Event *BodyBatteryEvent `json:"event"`
|
||||
ActivityName string `json:"activityName"`
|
||||
ActivityType string `json:"activityType"`
|
||||
ActivityID string `json:"activityId"`
|
||||
AverageStress float64 `json:"averageStress"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
BodyBatteryValuesArray [][]any `json:"bodyBatteryValuesArray"`
|
||||
}
|
||||
|
||||
// BodyBatteryReading represents an individual Body Battery reading
|
||||
type BodyBatteryReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Level int `json:"level"`
|
||||
Version float64 `json:"version"`
|
||||
}
|
||||
|
||||
// StressReading represents an individual stress reading
|
||||
type StressReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
}
|
||||
|
||||
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
readings := make([]BodyBatteryReading, 0)
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []types.BodyBatteryReading {
|
||||
readings := make([]types.BodyBatteryReading, 0)
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, ok1 := values[0].(int)
|
||||
timestamp, ok1 := values[0].(float64)
|
||||
status, ok2 := values[1].(string)
|
||||
level, ok3 := values[2].(int)
|
||||
level, ok3 := values[2].(float64)
|
||||
version, ok4 := values[3].(float64)
|
||||
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
continue
|
||||
}
|
||||
|
||||
readings = append(readings, BodyBatteryReading{
|
||||
Timestamp: timestamp,
|
||||
readings = append(readings, types.BodyBatteryReading{
|
||||
Timestamp: int(timestamp),
|
||||
Status: status,
|
||||
Level: level,
|
||||
Level: int(level),
|
||||
Version: version,
|
||||
})
|
||||
}
|
||||
@@ -91,48 +40,61 @@ func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
return readings
|
||||
}
|
||||
|
||||
// ParseStressReadings converts stress values array to structured readings
|
||||
func ParseStressReadings(valuesArray [][]int) []StressReading {
|
||||
readings := make([]StressReading, 0)
|
||||
for _, values := range valuesArray {
|
||||
if len(values) != 2 {
|
||||
continue
|
||||
}
|
||||
readings = append(readings, StressReading{
|
||||
Timestamp: values[0],
|
||||
StressLevel: values[1],
|
||||
})
|
||||
}
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailyBodyBatteryStress
|
||||
func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (d *types.DetailedBodyBatteryData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result DailyBodyBatteryStress
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (d *DailyBodyBatteryStress) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
// GetCurrentLevel returns the most recent Body Battery level
|
||||
func (d *types.DetailedBodyBatteryData) GetCurrentLevel() int {
|
||||
if len(d.BodyBatteryValuesArray) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level
|
||||
}
|
||||
|
||||
// GetDayChange returns the Body Battery change for the day
|
||||
func (d *types.DetailedBodyBatteryData) GetDayChange() int {
|
||||
readings := ParseBodyBatteryReadings(d.BodyBatteryValuesArray)
|
||||
if len(readings) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
return readings[len(readings)-1].Level - readings[0].Level
|
||||
}
|
||||
|
||||
@@ -2,153 +2,46 @@ package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/utils"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// HRVSummary represents Heart Rate Variability summary data
|
||||
type HRVSummary struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
WeeklyAvg float64 `json:"weeklyAvg"`
|
||||
LastNightAvg float64 `json:"lastNightAvg"`
|
||||
Baseline float64 `json:"baseline"`
|
||||
// Update the existing get method in hrv.go
|
||||
func (h *types.DailyHRVData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
type HRVReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
HeartRate int `json:"heartRate"`
|
||||
RRInterval int `json:"rrInterval"`
|
||||
Status string `json:"status"`
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return 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"`
|
||||
HRVSummary HRVSummary `json:"hrvSummary"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
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, len(valuesArray))
|
||||
// ParseHRVReadings converts body battery values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
||||
readings := make([]types.HRVReading, 0, len(valuesArray))
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 6 {
|
||||
continue
|
||||
@@ -162,7 +55,7 @@ func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
status, _ := values[4].(string)
|
||||
signalQuality, _ := values[5].(float64)
|
||||
|
||||
readings = append(readings, HRVReading{
|
||||
readings = append(readings, types.HRVReading{
|
||||
Timestamp: timestamp,
|
||||
StressLevel: stressLevel,
|
||||
HeartRate: heartRate,
|
||||
@@ -175,37 +68,4 @@ func ParseHRVReadings(valuesArray [][]any) []HRVReading {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
return readings
|
||||
}
|
||||
|
||||
// Get implements the Data interface for HRVData
|
||||
func (h *HRVData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
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
|
||||
func (h *HRVData) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
}
|
||||
}
|
||||
@@ -5,45 +5,27 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// SleepScores represents sleep scoring data
|
||||
type SleepScores struct {
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
SleepScores []struct {
|
||||
StartTimeGMT time.Time `json:"startTimeGmt"`
|
||||
EndTimeGMT time.Time `json:"endTimeGmt"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
} `json:"sleepScores"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
// Add other fields from Python implementation
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel int `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// DailySleepDTO represents daily sleep data
|
||||
type DailySleepDTO struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepScores SleepScores `json:"sleepScores"`
|
||||
BaseData
|
||||
SleepScores types.SleepScore `json:"sleepScores"` // Using types.SleepScore
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailySleepDTO
|
||||
func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (d *DailySleepDTO) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
client.Username, dateStr)
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -54,7 +36,7 @@ func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"` // Using types.SleepMovement
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
@@ -68,7 +50,7 @@ func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (d *DailySleepDTO) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
func (d *DailySleepDTO) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
// Implementation to be added
|
||||
return []any{}, nil
|
||||
}
|
||||
|
||||
68
internal/data/sleep_detailed.go
Normal file
68
internal/data/sleep_detailed.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
func (d *types.DetailedSleepData) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.GetUsername(), dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||
func (d *types.DetailedSleepData) GetSleepEfficiency() float64 {
|
||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
||||
if totalTime == 0 {
|
||||
return 0
|
||||
}
|
||||
return (sleepTime / totalTime) * 100
|
||||
}
|
||||
|
||||
// GetTotalSleepTime returns total sleep time in hours
|
||||
func (d *types.DetailedSleepData) GetTotalSleepTime() float64 {
|
||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
||||
return float64(totalSeconds) / 3600.0
|
||||
}
|
||||
57
internal/data/training.go
Normal file
57
internal/data/training.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
func (t *types.TrainingStatus) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (t *types.TrainingLoad) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
endDate := day.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
93
internal/data/vo2max.go
Normal file
93
internal/data/vo2max.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||
type VO2MaxData struct {
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
// NewVO2MaxData creates a new VO2MaxData instance
|
||||
func NewVO2MaxData() *VO2MaxData {
|
||||
vo2 := &VO2MaxData{}
|
||||
vo2.GetFunc = vo2.get
|
||||
return vo2
|
||||
}
|
||||
|
||||
// get implements the specific VO2 max data retrieval logic
|
||||
func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
// Primary approach: Get from user settings (most reliable)
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Extract VO2 max data from user settings
|
||||
vo2Profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// If no VO2 max data found, still return valid empty profile
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// List implements concurrent fetching for multiple days
|
||||
// Note: VO2 max typically doesn't change daily, so this returns the same values
|
||||
func (v *VO2MaxData) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]interface{}, []error) {
|
||||
// For VO2 max, we want current values from user settings
|
||||
vo2Data, err := v.get(end, c)
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
// Return the same VO2 max data for all requested days
|
||||
results := make([]interface{}, days)
|
||||
for i := 0; i < days; i++ {
|
||||
results[i] = vo2Data
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
||||
func GetCurrentVO2Max(c shared.APIClient) (*types.VO2MaxProfile, error) {
|
||||
vo2Data := NewVO2MaxData()
|
||||
result, err := vo2Data.get(time.Now(), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vo2Profile, ok := result.(*types.VO2MaxProfile)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected result type")
|
||||
}
|
||||
|
||||
return vo2Profile, nil
|
||||
}
|
||||
70
internal/data/vo2max_test.go
Normal file
70
internal/data/vo2max_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/models"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVO2MaxData_Get(t *testing.T) {
|
||||
// Setup
|
||||
runningVO2 := 45.0
|
||||
cyclingVO2 := 50.0
|
||||
settings := &client.UserSettings{
|
||||
ID: 12345,
|
||||
UserData: client.UserData{
|
||||
VO2MaxRunning: &runningVO2,
|
||||
VO2MaxCycling: &cyclingVO2,
|
||||
},
|
||||
}
|
||||
|
||||
vo2Data := NewVO2MaxData()
|
||||
|
||||
// Mock the get function
|
||||
vo2Data.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
vo2Profile := &models.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &models.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// Test
|
||||
result, err := vo2Data.Get(time.Now(), nil) // client is not used in this mocked get
|
||||
|
||||
// Assertions
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
profile, ok := result.(*models.VO2MaxProfile)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 12345, profile.UserProfilePK)
|
||||
assert.NotNil(t, profile.Running)
|
||||
assert.Equal(t, 45.0, profile.Running.Value)
|
||||
assert.Equal(t, "running", profile.Running.ActivityType)
|
||||
assert.NotNil(t, profile.Cycling)
|
||||
assert.Equal(t, 50.0, profile.Cycling.Value)
|
||||
assert.Equal(t, "cycling", profile.Cycling.ActivityType)
|
||||
}
|
||||
@@ -5,25 +5,12 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
shared "go-garth/shared/interfaces"
|
||||
types "go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// WeightData represents weight measurement data
|
||||
type WeightData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
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 {
|
||||
func (w *types.WeightData) Validate() error {
|
||||
if w.Weight <= 0 {
|
||||
return fmt.Errorf("invalid weight value")
|
||||
}
|
||||
@@ -34,13 +21,13 @@ func (w *WeightData) Validate() error {
|
||||
}
|
||||
|
||||
// Get implements the Data interface for WeightData
|
||||
func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
func (w *types.WeightData) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
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)
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -50,7 +37,7 @@ func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
var response struct {
|
||||
WeightList []WeightData `json:"weightList"`
|
||||
WeightList []types.WeightData `json:"weightList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
@@ -71,8 +58,8 @@ func (w *WeightData) Get(day time.Time, client *client.Client) (any, error) {
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (w *WeightData) List(end time.Time, days int, client *client.Client, maxWorkers int) ([]any, error) {
|
||||
results, errs := w.BaseData.List(end, days, client, maxWorkers)
|
||||
func (w *types.WeightData) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
results, errs := w.BaseData.List(end, days, c, maxWorkers)
|
||||
if len(errs) > 0 {
|
||||
// Return first error for now
|
||||
return results, errs[0]
|
||||
|
||||
415
internal/models/types/garmin.go
Normal file
415
internal/models/types/garmin.go
Normal file
@@ -0,0 +1,415 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// Default location for conversions (set to UTC by default)
|
||||
defaultLocation *time.Location
|
||||
)
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
defaultLocation, err = time.LoadLocation("UTC")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTimestamp converts a millisecond timestamp to time.Time in default location
|
||||
func ParseTimestamp(ts int) time.Time {
|
||||
return time.Unix(0, int64(ts)*int64(time.Millisecond)).In(defaultLocation)
|
||||
}
|
||||
|
||||
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
|
||||
func ParseAggregationKey(key, aggregate string) time.Time {
|
||||
switch aggregate {
|
||||
case "day":
|
||||
t, _ := time.Parse("2006-01-02", key)
|
||||
return t
|
||||
case "week":
|
||||
year, _ := strconv.Atoi(key[:4])
|
||||
week, _ := strconv.Atoi(key[6:])
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Find the first Monday of the year
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
// Add weeks
|
||||
return t.AddDate(0, 0, (week-1)*7)
|
||||
case "month":
|
||||
t, _ := time.Parse("2006-01", key)
|
||||
return t
|
||||
case "year":
|
||||
t, _ := time.Parse("2006", key)
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
// It parses Garmin's specific timestamp format.
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
|
||||
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
|
||||
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
|
||||
// If the input string does not contain 'Z', it will be parsed as local time.
|
||||
// For consistency, we'll assume UTC if no timezone is specified.
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
|
||||
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
|
||||
"2006-01-02", // Example: 2018-09-01
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot parse %q into a GarminTime", s)
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// ActivityType represents the type of activity
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID *int `json:"parentTypeId,omitempty"`
|
||||
}
|
||||
|
||||
// EventType represents the event type of an activity
|
||||
type EventType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
}
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR float64 `json:"averageHR"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning *float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling *float64 `json:"vo2MaxCycling"`
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
}
|
||||
|
||||
// Add these new structs
|
||||
type VO2MaxEntry struct {
|
||||
Value float64 `json:"value"`
|
||||
ActivityType string `json:"activityType"` // "running" or "cycling"
|
||||
Date time.Time `json:"date"`
|
||||
Source string `json:"source"` // "user_settings", "activity", etc.
|
||||
}
|
||||
|
||||
type VO2Max struct {
|
||||
Value float64 `json:"vo2Max"`
|
||||
FitnessLevel string `json:"fitnessLevel"`
|
||||
UpdatedDate time.Time `json:"date"`
|
||||
}
|
||||
|
||||
// SleepLevel represents different sleep stages
|
||||
type SleepLevel struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
SleepLevel string `json:"sleepLevel"` // "deep", "light", "rem", "awake"
|
||||
}
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement struct {
|
||||
StartGMT time.Time `json:"startGmt"`
|
||||
EndGMT time.Time `json:"endGmt"`
|
||||
ActivityLevel float64 `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepScore represents detailed sleep scoring
|
||||
type SleepScore struct {
|
||||
Overall int `json:"overall"`
|
||||
Composition SleepScoreBreakdown `json:"composition"`
|
||||
Revitalization SleepScoreBreakdown `json:"revitalization"`
|
||||
Duration SleepScoreBreakdown `json:"duration"`
|
||||
DeepPercentage float64 `json:"deepPercentage"`
|
||||
LightPercentage float64 `json:"lightPercentage"`
|
||||
RemPercentage float64 `json:"remPercentage"`
|
||||
RestfulnessValue float64 `json:"restfulnessValue"`
|
||||
}
|
||||
|
||||
type SleepScoreBreakdown struct {
|
||||
QualifierKey string `json:"qualifierKey"`
|
||||
OptimalStart float64 `json:"optimalStart"`
|
||||
OptimalEnd float64 `json:"optimalEnd"`
|
||||
Value float64 `json:"value"`
|
||||
IdealStartSecs *int `json:"idealStartInSeconds"`
|
||||
IdealEndSecs *int `json:"idealEndInSeconds"`
|
||||
}
|
||||
|
||||
// DetailedSleepData represents comprehensive sleep data
|
||||
type DetailedSleepData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepStartTimestampLocal time.Time `json:"sleepStartTimestampLocal"`
|
||||
SleepEndTimestampLocal time.Time `json:"sleepEndTimestampLocal"`
|
||||
UnmeasurableSleepSeconds int `json:"unmeasurableSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
DeviceRemCapable bool `json:"deviceRemCapable"`
|
||||
SleepLevels []SleepLevel `json:"sleepLevels"`
|
||||
SleepMovement []SleepMovement `json:"sleepMovement"`
|
||||
SleepScores *SleepScore `json:"sleepScores"`
|
||||
AverageSpO2Value *float64 `json:"averageSpO2Value"`
|
||||
LowestSpO2Value *int `json:"lowestSpO2Value"`
|
||||
HighestSpO2Value *int `json:"highestSpO2Value"`
|
||||
AverageRespirationValue *float64 `json:"averageRespirationValue"`
|
||||
LowestRespirationValue *float64 `json:"lowestRespirationValue"`
|
||||
HighestRespirationValue *float64 `json:"highestRespirationValue"`
|
||||
AvgSleepStress *float64 `json:"avgSleepStress"`
|
||||
}
|
||||
|
||||
// HRVBaseline represents HRV baseline data
|
||||
type HRVBaseline struct {
|
||||
LowUpper int `json:"lowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// DailyHRVData represents comprehensive daily HRV data
|
||||
type DailyHRVData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
WeeklyAvg *float64 `json:"weeklyAvg"`
|
||||
LastNightAvg *float64 `json:"lastNightAvg"`
|
||||
LastNight5MinHigh *float64 `json:"lastNight5MinHigh"`
|
||||
Baseline HRVBaseline `json:"baseline"`
|
||||
Status string `json:"status"`
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
CreateTimeStamp time.Time `json:"createTimeStamp"`
|
||||
HRVReadings []HRVReading `json:"hrvReadings"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
}
|
||||
|
||||
// BodyBatteryEvent represents events that impact Body Battery
|
||||
type BodyBatteryEvent struct {
|
||||
EventType string `json:"eventType"` // "sleep", "activity", "stress"
|
||||
EventStartTimeGMT time.Time `json:"eventStartTimeGmt"`
|
||||
TimezoneOffset int `json:"timezoneOffset"`
|
||||
DurationInMilliseconds int `json:"durationInMilliseconds"`
|
||||
BodyBatteryImpact int `json:"bodyBatteryImpact"`
|
||||
FeedbackType string `json:"feedbackType"`
|
||||
ShortFeedback string `json:"shortFeedback"`
|
||||
}
|
||||
|
||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||
type DetailedBodyBatteryData struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
StartTimestampGMT time.Time `json:"startTimestampGmt"`
|
||||
EndTimestampGMT time.Time `json:"endTimestampGmt"`
|
||||
StartTimestampLocal time.Time `json:"startTimestampLocal"`
|
||||
EndTimestampLocal time.Time `json:"endTimestampLocal"`
|
||||
MaxStressLevel int `json:"maxStressLevel"`
|
||||
AvgStressLevel int `json:"avgStressLevel"`
|
||||
BodyBatteryValuesArray [][]interface{} `json:"bodyBatteryValuesArray"`
|
||||
StressValuesArray [][]int `json:"stressValuesArray"`
|
||||
Events []BodyBatteryEvent `json:"bodyBatteryEvents"`
|
||||
}
|
||||
|
||||
// TrainingStatus represents current training status
|
||||
type TrainingStatus struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
TrainingStatusKey string `json:"trainingStatusKey"` // "DETRAINING", "RECOVERY", "MAINTAINING", "PRODUCTIVE", "PEAKING", "OVERREACHING", "UNPRODUCTIVE", "NONE"
|
||||
TrainingStatusTypeKey string `json:"trainingStatusTypeKey"`
|
||||
TrainingStatusValue int `json:"trainingStatusValue"`
|
||||
LoadRatio float64 `json:"loadRatio"`
|
||||
}
|
||||
|
||||
// TrainingLoad represents training load data
|
||||
type TrainingLoad struct {
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
AcuteTrainingLoad float64 `json:"acuteTrainingLoad"`
|
||||
ChronicTrainingLoad float64 `json:"chronicTrainingLoad"`
|
||||
TrainingLoadRatio float64 `json:"trainingLoadRatio"`
|
||||
TrainingEffectAerobic float64 `json:"trainingEffectAerobic"`
|
||||
TrainingEffectAnaerobic float64 `json:"trainingEffectAnaerobic"`
|
||||
}
|
||||
|
||||
// FitnessAge represents fitness age calculation
|
||||
type FitnessAge struct {
|
||||
FitnessAge int `json:"fitnessAge"`
|
||||
ChronologicalAge int `json:"chronologicalAge"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HRVStatus represents HRV status and baseline
|
||||
type HRVStatus struct {
|
||||
Status string `json:"status"` // "BALANCED", "UNBALANCED", "POOR"
|
||||
FeedbackPhrase string `json:"feedbackPhrase"`
|
||||
BaselineLowUpper int `json:"baselineLowUpper"`
|
||||
BalancedLow int `json:"balancedLow"`
|
||||
BalancedUpper int `json:"balancedUpper"`
|
||||
MarkerValue float64 `json:"markerValue"`
|
||||
}
|
||||
|
||||
// HRVReading represents an individual HRV reading
|
||||
type HRVReading struct {
|
||||
Timestamp int `json:"timestamp"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
HeartRate int `json:"heartRate"`
|
||||
RRInterval int `json:"rrInterval"`
|
||||
Status string `json:"status"`
|
||||
SignalQuality float64 `json:"signalQuality"`
|
||||
}
|
||||
|
||||
// TimestampAsTime converts the reading timestamp to time.Time using timeutils
|
||||
func (r *HRVReading) TimestampAsTime() time.Time {
|
||||
return ParseTimestamp(r.Timestamp)
|
||||
}
|
||||
|
||||
// RRSeconds converts the RR interval to seconds
|
||||
func (r *HRVReading) RRSeconds() float64 {
|
||||
return float64(r.RRInterval) / 1000.0
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StepsData represents steps statistics
|
||||
type StepsData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
// DistanceData represents distance statistics
|
||||
type DistanceData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
}
|
||||
|
||||
// CaloriesData represents calories statistics
|
||||
type CaloriesData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Calories int `json:"activeCalories"`
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements the json.Unmarshaler interface.
|
||||
// It parses Garmin's specific timestamp format.
|
||||
func (gt *GarminTime) UnmarshalJSON(b []byte) (err error) {
|
||||
s := strings.Trim(string(b), `"`)
|
||||
if s == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try parsing with milliseconds (e.g., "2018-09-01T00:13:25.000")
|
||||
// Garmin sometimes returns .0 for milliseconds, which Go's time.Parse handles as .000
|
||||
// The 'Z' in the layout indicates a UTC time without a specific offset, which is often how these are interpreted.
|
||||
// If the input string does not contain 'Z', it will be parsed as local time.
|
||||
// For consistency, we'll assume UTC if no timezone is specified.
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05.0", // Example: 2018-09-01T00:13:25.0
|
||||
"2006-01-02T15:04:05", // Example: 2018-09-01T00:13:25
|
||||
"2006-01-02", // Example: 2018-09-01
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
gt.Time = t
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot parse %q into a GarminTime", s)
|
||||
}
|
||||
|
||||
// SessionData represents saved session information
|
||||
type SessionData struct {
|
||||
Domain string `json:"domain"`
|
||||
Username string `json:"username"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// ActivityType represents the type of activity
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID *int `json:"parentTypeId,omitempty"`
|
||||
}
|
||||
|
||||
// EventType represents the event type of an activity
|
||||
type EventType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
}
|
||||
|
||||
// Activity represents a Garmin Connect activity
|
||||
type Activity struct {
|
||||
ActivityID int64 `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartTimeLocal GarminTime `json:"startTimeLocal"`
|
||||
StartTimeGMT GarminTime `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
EventType EventType `json:"eventType"`
|
||||
Distance float64 `json:"distance"`
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
ElevationGain float64 `json:"elevationGain"`
|
||||
ElevationLoss float64 `json:"elevationLoss"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHR float64 `json:"averageHR"`
|
||||
MaxHR float64 `json:"maxHR"`
|
||||
}
|
||||
|
||||
// UserProfile represents a Garmin user profile
|
||||
type UserProfile struct {
|
||||
UserName string `json:"userName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||
// Add other fields as needed from API response
|
||||
}
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling float64 `json:"vo2MaxCycling"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
|
||||
// StepsData represents steps statistics
|
||||
type StepsData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Steps int `json:"steps"`
|
||||
}
|
||||
|
||||
// DistanceData represents distance statistics
|
||||
type DistanceData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Distance float64 `json:"distance"` // in meters
|
||||
}
|
||||
|
||||
// CaloriesData represents calories statistics
|
||||
type CaloriesData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
Calories int `json:"activeCalories"`
|
||||
}
|
||||
@@ -2,64 +2,20 @@ package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
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)
|
||||
// defaultLocation = loc
|
||||
}
|
||||
|
||||
// ToLocalTime converts UTC time to local time using default location
|
||||
func ToLocalTime(utcTime time.Time) time.Time {
|
||||
return utcTime.In(defaultLocation)
|
||||
// return utcTime.In(defaultLocation)
|
||||
return utcTime // TODO: Implement proper time zone conversion
|
||||
}
|
||||
|
||||
// ToUTCTime converts local time to UTC
|
||||
func ToUTCTime(localTime time.Time) time.Time {
|
||||
return localTime.UTC()
|
||||
}
|
||||
|
||||
// parseAggregationKey is a helper function to parse aggregation key back to a time.Time object
|
||||
func ParseAggregationKey(key, aggregate string) time.Time {
|
||||
switch aggregate {
|
||||
case "day":
|
||||
t, _ := time.Parse("2006-01-02", key)
|
||||
return t
|
||||
case "week":
|
||||
year, _ := strconv.Atoi(key[:4])
|
||||
week, _ := strconv.Atoi(key[6:])
|
||||
t := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
// Find the first Monday of the year
|
||||
for t.Weekday() != time.Monday {
|
||||
t = t.AddDate(0, 0, 1)
|
||||
}
|
||||
// Add weeks
|
||||
return t.AddDate(0, 0, (week-1)*7)
|
||||
case "month":
|
||||
t, _ := time.Parse("2006-01", key)
|
||||
return t
|
||||
case "year":
|
||||
t, _ := time.Parse("2006", key)
|
||||
return t
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"go-garth/internal/types"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -16,10 +15,16 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var oauthConsumer *types.OAuthConsumer
|
||||
// OAuthConsumer represents OAuth consumer credentials
|
||||
type OAuthConsumer struct {
|
||||
ConsumerKey string `json:"consumer_key"`
|
||||
ConsumerSecret string `json:"consumer_secret"`
|
||||
}
|
||||
|
||||
var oauthConsumer *OAuthConsumer
|
||||
|
||||
// LoadOAuthConsumer loads OAuth consumer credentials
|
||||
func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
func LoadOAuthConsumer() (*OAuthConsumer, error) {
|
||||
if oauthConsumer != nil {
|
||||
return oauthConsumer, nil
|
||||
}
|
||||
@@ -29,7 +34,7 @@ func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
var consumer types.OAuthConsumer
|
||||
var consumer OAuthConsumer
|
||||
if err := json.NewDecoder(resp.Body).Decode(&consumer); err == nil {
|
||||
oauthConsumer = &consumer
|
||||
return oauthConsumer, nil
|
||||
@@ -38,7 +43,7 @@ func LoadOAuthConsumer() (*types.OAuthConsumer, error) {
|
||||
}
|
||||
|
||||
// Fallback to hardcoded values
|
||||
oauthConsumer = &types.OAuthConsumer{
|
||||
oauthConsumer = &OAuthConsumer{
|
||||
ConsumerKey: "fc320c35-fbdc-4308-b5c6-8e41a8b2e0c8",
|
||||
ConsumerSecret: "8b344b8c-5bd5-4b7b-9c98-ad76a6bbf0e7",
|
||||
}
|
||||
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "go-garth",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
internalClient "go-garth/internal/api/client"
|
||||
"go-garth/internal/errors"
|
||||
"go-garth/internal/types"
|
||||
types "go-garth/internal/models/types"
|
||||
shared "go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// Client is the main Garmin Connect client type
|
||||
@@ -16,6 +17,8 @@ type Client struct {
|
||||
Client *internalClient.Client
|
||||
}
|
||||
|
||||
var _ shared.APIClient = (*Client)(nil)
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
c, err := internalClient.NewClient(domain)
|
||||
@@ -25,6 +28,35 @@ func NewClient(domain string) (*Client, error) {
|
||||
return &Client{Client: c}, nil
|
||||
}
|
||||
|
||||
func (c *Client) InternalClient() *internalClient.Client {
|
||||
return c.Client
|
||||
}
|
||||
|
||||
// ConnectAPI implements the APIClient interface
|
||||
func (c *Client) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
return c.Client.ConnectAPI(path, method, params, body)
|
||||
}
|
||||
|
||||
// GetUsername implements the APIClient interface
|
||||
func (c *Client) GetUsername() string {
|
||||
return c.Client.GetUsername()
|
||||
}
|
||||
|
||||
// GetUserSettings implements the APIClient interface
|
||||
func (c *Client) GetUserSettings() (*types.UserSettings, error) {
|
||||
return c.Client.GetUserSettings()
|
||||
}
|
||||
|
||||
// GetUserProfile implements the APIClient interface
|
||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
return c.Client.GetUserProfile()
|
||||
}
|
||||
|
||||
// GetWellnessData implements the APIClient interface
|
||||
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) {
|
||||
return c.Client.GetWellnessData(startDate, endDate)
|
||||
}
|
||||
|
||||
// Login authenticates to Garmin Connect
|
||||
func (c *Client) Login(email, password string) error {
|
||||
return c.Client.Login(email, password)
|
||||
@@ -133,13 +165,13 @@ func (c *Client) SearchActivities(query string) ([]Activity, error) {
|
||||
}
|
||||
|
||||
// GetSleepData retrieves sleep data for a specified date range
|
||||
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) {
|
||||
return c.Client.GetSleepData(startDate, endDate)
|
||||
func (c *Client) GetSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
return c.Client.GetDetailedSleepData(date)
|
||||
}
|
||||
|
||||
// GetHrvData retrieves HRV data for a specified number of days
|
||||
func (c *Client) GetHrvData(days int) ([]types.HrvData, error) {
|
||||
return c.Client.GetHrvData(days)
|
||||
func (c *Client) GetHrvData(date time.Time) (*types.DailyHRVData, error) {
|
||||
return c.Client.GetDailyHRVData(date)
|
||||
}
|
||||
|
||||
// GetStressData retrieves stress data
|
||||
@@ -148,8 +180,8 @@ func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData
|
||||
}
|
||||
|
||||
// GetBodyBatteryData retrieves Body Battery data
|
||||
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) {
|
||||
return c.Client.GetBodyBatteryData(startDate, endDate)
|
||||
func (c *Client) GetBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
||||
return c.Client.GetDetailedBodyBatteryData(date)
|
||||
}
|
||||
|
||||
// GetStepsData retrieves steps data for a specified date range
|
||||
@@ -182,6 +214,21 @@ func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.Wellness
|
||||
return c.Client.GetWellnessData(startDate, endDate)
|
||||
}
|
||||
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
return c.Client.GetTrainingStatus(date)
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
return c.Client.GetTrainingLoad(date)
|
||||
}
|
||||
|
||||
// GetFitnessAge retrieves fitness age calculation
|
||||
func (c *Client) GetFitnessAge() (*types.FitnessAge, error) {
|
||||
return c.Client.GetFitnessAge()
|
||||
}
|
||||
|
||||
// OAuth1Token returns the OAuth1 token
|
||||
func (c *Client) OAuth1Token() *types.OAuth1Token {
|
||||
return c.Client.OAuth1Token
|
||||
@@ -190,4 +237,4 @@ func (c *Client) OAuth1Token() *types.OAuth1Token {
|
||||
// OAuth2Token returns the OAuth2 token
|
||||
func (c *Client) OAuth2Token() *types.OAuth2Token {
|
||||
return c.Client.OAuth2Token
|
||||
}
|
||||
}
|
||||
@@ -1,79 +1,88 @@
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
internalClient "go-garth/internal/api/client"
|
||||
"go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
SleepScore int `json:"sleepScore"`
|
||||
TotalSleepSeconds int `json:"totalSleepSeconds"`
|
||||
DeepSleepSeconds int `json:"deepSleepSeconds"`
|
||||
LightSleepSeconds int `json:"lightSleepSeconds"`
|
||||
RemSleepSeconds int `json:"remSleepSeconds"`
|
||||
AwakeSleepSeconds int `json:"awakeSleepSeconds"`
|
||||
// Add more fields as needed
|
||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
||||
return getDailyHRVData(date, c.Client)
|
||||
}
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
HrvValue float64 `json:"hrvValue"`
|
||||
// Add more fields as needed
|
||||
func getDailyHRVData(day time.Time, client *internalClient.Client) (*types.DailyHRVData, error) {
|
||||
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, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
StressLevel int `json:"stressLevel"`
|
||||
RestStressLevel int `json:"restStressLevel"`
|
||||
// Add more fields as needed
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
return getDetailedSleepData(date, c.Client)
|
||||
}
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
BatteryLevel int `json:"batteryLevel"`
|
||||
Charge int `json:"charge"`
|
||||
Drain int `json:"drain"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
func getDetailedSleepData(day time.Time, client *internalClient.Client) (*types.DetailedSleepData, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
client.Username, dateStr)
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling float64 `json:"vo2MaxCycling"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
data, err := client.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones struct {
|
||||
RestingHR int `json:"resting_hr"`
|
||||
MaxHR int `json:"max_hr"`
|
||||
LactateThreshold int `json:"lactate_threshold"`
|
||||
Zones []HRZone `json:"zones"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone struct {
|
||||
Zone int `json:"zone"`
|
||||
MinBPM int `json:"min_bpm"`
|
||||
MaxBPM int `json:"max_bpm"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
RestingHR *int `json:"resting_hr"`
|
||||
Weight *float64 `json:"weight"`
|
||||
BodyFat *float64 `json:"body_fat"`
|
||||
BMI *float64 `json:"bmi"`
|
||||
BodyWater *float64 `json:"body_water"`
|
||||
BoneMass *float64 `json:"bone_mass"`
|
||||
MuscleMass *float64 `json:"muscle_mass"`
|
||||
// Add more fields as needed
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package garmin
|
||||
|
||||
import "go-garth/internal/types"
|
||||
import types "go-garth/internal/models/types"
|
||||
|
||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||
type GarminTime = types.GarminTime
|
||||
@@ -24,4 +24,67 @@ type UserProfile = types.UserProfile
|
||||
type OAuth1Token = types.OAuth1Token
|
||||
|
||||
// OAuth2Token represents OAuth2 token response
|
||||
type OAuth2Token = types.OAuth2Token
|
||||
type OAuth2Token = types.OAuth2Token
|
||||
|
||||
// DetailedSleepData represents comprehensive sleep data
|
||||
type DetailedSleepData = types.DetailedSleepData
|
||||
|
||||
// SleepLevel represents different sleep stages
|
||||
type SleepLevel = types.SleepLevel
|
||||
|
||||
// SleepMovement represents movement during sleep
|
||||
type SleepMovement = types.SleepMovement
|
||||
|
||||
// SleepScore represents detailed sleep scoring
|
||||
type SleepScore = types.SleepScore
|
||||
|
||||
// SleepScoreBreakdown represents breakdown of sleep score
|
||||
type SleepScoreBreakdown = types.SleepScoreBreakdown
|
||||
|
||||
// HRVBaseline represents HRV baseline data
|
||||
type HRVBaseline = types.HRVBaseline
|
||||
|
||||
// DailyHRVData represents comprehensive daily HRV data
|
||||
type DailyHRVData = types.DailyHRVData
|
||||
|
||||
// BodyBatteryEvent represents events that impact Body Battery
|
||||
type BodyBatteryEvent = types.BodyBatteryEvent
|
||||
|
||||
// DetailedBodyBatteryData represents comprehensive Body Battery data
|
||||
type DetailedBodyBatteryData = types.DetailedBodyBatteryData
|
||||
|
||||
// TrainingStatus represents current training status
|
||||
type TrainingStatus = types.TrainingStatus
|
||||
|
||||
// TrainingLoad represents training load data
|
||||
type TrainingLoad = types.TrainingLoad
|
||||
|
||||
// FitnessAge represents fitness age calculation
|
||||
type FitnessAge = types.FitnessAge
|
||||
|
||||
// VO2MaxData represents VO2 max data
|
||||
type VO2MaxData = types.VO2MaxData
|
||||
|
||||
// VO2MaxEntry represents a single VO2 max entry
|
||||
type VO2MaxEntry = types.VO2MaxEntry
|
||||
|
||||
// HeartRateZones represents heart rate zone data
|
||||
type HeartRateZones = types.HeartRateZones
|
||||
|
||||
// HRZone represents a single heart rate zone
|
||||
type HRZone = types.HRZone
|
||||
|
||||
// WellnessData represents additional wellness metrics
|
||||
type WellnessData = types.WellnessData
|
||||
|
||||
// SleepData represents sleep summary data
|
||||
type SleepData = types.SleepData
|
||||
|
||||
// HrvData represents Heart Rate Variability data
|
||||
type HrvData = types.HrvData
|
||||
|
||||
// StressData represents stress level data
|
||||
type StressData = types.StressData
|
||||
|
||||
// BodyBatteryData represents Body Battery data
|
||||
type BodyBatteryData = types.BodyBatteryData
|
||||
|
||||
229
python-garmin-connect/Activity.go
Normal file
229
python-garmin-connect/Activity.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Activity describes a Garmin Connect activity.
|
||||
type Activity struct {
|
||||
ID int `json:"activityId"`
|
||||
ActivityName string `json:"activityName"`
|
||||
Description string `json:"description"`
|
||||
StartLocal Time `json:"startTimeLocal"`
|
||||
StartGMT Time `json:"startTimeGMT"`
|
||||
ActivityType ActivityType `json:"activityType"`
|
||||
Distance float64 `json:"distance"` // meter
|
||||
Duration float64 `json:"duration"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
MovingDuration float64 `json:"movingDuration"`
|
||||
AverageSpeed float64 `json:"averageSpeed"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
OwnerID int `json:"ownerId"`
|
||||
Calories float64 `json:"calories"`
|
||||
AverageHeartRate float64 `json:"averageHR"`
|
||||
MaxHeartRate float64 `json:"maxHR"`
|
||||
DeviceID int `json:"deviceId"`
|
||||
}
|
||||
|
||||
// ActivityType describes the type of activity.
|
||||
type ActivityType struct {
|
||||
TypeID int `json:"typeId"`
|
||||
TypeKey string `json:"typeKey"`
|
||||
ParentTypeID int `json:"parentTypeId"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}
|
||||
|
||||
// Activity will retrieve details about an activity.
|
||||
func (c *Client) Activity(activityID int) (*Activity, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/activity-service/activity/%d",
|
||||
activityID,
|
||||
)
|
||||
|
||||
activity := new(Activity)
|
||||
|
||||
err := c.getJSON(URL, &activity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
// Activities will list activities for displayName. If displayName is empty,
|
||||
// the authenticated user will be used.
|
||||
func (c *Client) Activities(displayName string, start int, limit int) ([]Activity, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/activitylist-service/activities/%s?start=%d&limit=%d", displayName, start, limit)
|
||||
|
||||
if !c.authenticated() && displayName == "" {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy struct {
|
||||
List []Activity `json:"activityList"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxy.List, nil
|
||||
}
|
||||
|
||||
// RenameActivity can be used to rename an activity.
|
||||
func (c *Client) RenameActivity(activityID int, newName string) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/activity-service/activity/%d", activityID)
|
||||
|
||||
payload := struct {
|
||||
ID int `json:"activityId"`
|
||||
Name string `json:"activityName"`
|
||||
}{activityID, newName}
|
||||
|
||||
return c.write("PUT", URL, payload, 204)
|
||||
}
|
||||
|
||||
// ExportActivity will export an activity from Connect. The activity will be written til w.
|
||||
func (c *Client) ExportActivity(id int, w io.Writer, format ActivityFormat) error {
|
||||
formatTable := [activityFormatMax]string{
|
||||
"https://connect.garmin.com/modern/proxy/download-service/files/activity/%d",
|
||||
"https://connect.garmin.com/modern/proxy/download-service/export/tcx/activity/%d",
|
||||
"https://connect.garmin.com/modern/proxy/download-service/export/gpx/activity/%d",
|
||||
"https://connect.garmin.com/modern/proxy/download-service/export/kml/activity/%d",
|
||||
"https://connect.garmin.com/modern/proxy/download-service/export/csv/activity/%d",
|
||||
}
|
||||
|
||||
if format >= activityFormatMax || format < ActivityFormatFIT {
|
||||
return errors.New("invalid format")
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf(formatTable[format], id)
|
||||
|
||||
// To unzip FIT files on-the-fly, we treat them specially.
|
||||
if format == ActivityFormatFIT {
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
|
||||
err := c.Download(URL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
z, err := zip.NewReader(bytes.NewReader(buffer.Bytes()), int64(buffer.Len()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(z.File) != 1 {
|
||||
return fmt.Errorf("%d files found in FIT archive, 1 expected", len(z.File))
|
||||
}
|
||||
|
||||
src, err := z.File[0].Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
_, err = io.Copy(w, src)
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Download(URL, w)
|
||||
}
|
||||
|
||||
// ImportActivity will import an activity into Garmin Connect. The activity
|
||||
// will be read from file.
|
||||
func (c *Client) ImportActivity(file io.Reader, format ActivityFormat) (int, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/upload-service/upload/." + format.Extension()
|
||||
|
||||
switch format {
|
||||
case ActivityFormatFIT, ActivityFormatTCX, ActivityFormatGPX:
|
||||
// These are ok.
|
||||
default:
|
||||
return 0, fmt.Errorf("%s is not supported for import", format.Extension())
|
||||
}
|
||||
|
||||
formData := bytes.Buffer{}
|
||||
writer := multipart.NewWriter(&formData)
|
||||
defer writer.Close()
|
||||
|
||||
activity, err := writer.CreateFormFile("file", "activity."+format.Extension())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(activity, file)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
writer.Close()
|
||||
|
||||
req, err := c.newRequest("POST", URL, &formData)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
req.Header.Add("content-type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Implement enough of the response to satisfy our needs.
|
||||
var response struct {
|
||||
ImportResult struct {
|
||||
Successes []struct {
|
||||
InternalID int `json:"internalId"`
|
||||
} `json:"successes"`
|
||||
|
||||
Failures []struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
} `json:"failures"`
|
||||
} `json:"detailedImportResult"`
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// This is ugly.
|
||||
if len(response.ImportResult.Failures) > 0 {
|
||||
messages := make([]string, 0, 10)
|
||||
for _, f := range response.ImportResult.Failures {
|
||||
for _, m := range f.Messages {
|
||||
messages = append(messages, m.Content)
|
||||
}
|
||||
}
|
||||
|
||||
return 0, errors.New(strings.Join(messages, "; "))
|
||||
}
|
||||
|
||||
if resp.StatusCode != 201 {
|
||||
return 0, fmt.Errorf("%d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
|
||||
}
|
||||
|
||||
if len(response.ImportResult.Successes) != 1 {
|
||||
return 0, Error("cannot parse response, no failures and no successes..?")
|
||||
}
|
||||
|
||||
return response.ImportResult.Successes[0].InternalID, nil
|
||||
}
|
||||
|
||||
// DeleteActivity will permanently delete an activity.
|
||||
func (c *Client) DeleteActivity(id int) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/activity-service/activity/%d", id)
|
||||
|
||||
return c.write("DELETE", URL, nil, 0)
|
||||
}
|
||||
75
python-garmin-connect/ActivityFormat.go
Normal file
75
python-garmin-connect/ActivityFormat.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActivityFormat is a file format for importing and exporting activities.
|
||||
type ActivityFormat int
|
||||
|
||||
const (
|
||||
// ActivityFormatFIT is the "original" Garmin format.
|
||||
ActivityFormatFIT ActivityFormat = iota
|
||||
|
||||
// ActivityFormatTCX is Training Center XML (TCX) format.
|
||||
ActivityFormatTCX
|
||||
|
||||
// ActivityFormatGPX will export as GPX - the GPS Exchange Format.
|
||||
ActivityFormatGPX
|
||||
|
||||
// ActivityFormatKML will export KML files compatible with Google Earth.
|
||||
ActivityFormatKML
|
||||
|
||||
// ActivityFormatCSV will export splits as CSV.
|
||||
ActivityFormatCSV
|
||||
|
||||
activityFormatMax
|
||||
activityFormatInvalid
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrUnknownFormat will be returned if the activity file format is unknown.
|
||||
ErrUnknownFormat = Error("Unknown format")
|
||||
)
|
||||
|
||||
var (
|
||||
activityFormatTable = map[string]ActivityFormat{
|
||||
"fit": ActivityFormatFIT,
|
||||
"tcx": ActivityFormatTCX,
|
||||
"gpx": ActivityFormatGPX,
|
||||
"kml": ActivityFormatKML,
|
||||
"csv": ActivityFormatCSV,
|
||||
}
|
||||
)
|
||||
|
||||
// Extension returns an appropriate filename extension for format.
|
||||
func (f ActivityFormat) Extension() string {
|
||||
for extension, format := range activityFormatTable {
|
||||
if format == f {
|
||||
return extension
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatFromExtension tries to guess the format from a file extension.
|
||||
func FormatFromExtension(extension string) (ActivityFormat, error) {
|
||||
extension = strings.ToLower(extension)
|
||||
|
||||
format, found := activityFormatTable[extension]
|
||||
if !found {
|
||||
return activityFormatInvalid, ErrUnknownFormat
|
||||
}
|
||||
|
||||
return format, nil
|
||||
}
|
||||
|
||||
// FormatFromFilename tries to guess the format based on a filename (or path).
|
||||
func FormatFromFilename(filename string) (ActivityFormat, error) {
|
||||
extension := filepath.Ext(filename)
|
||||
extension = strings.TrimPrefix(extension, ".")
|
||||
|
||||
return FormatFromExtension(extension)
|
||||
}
|
||||
41
python-garmin-connect/ActivityHrZones.go
Normal file
41
python-garmin-connect/ActivityHrZones.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ActivityHrZones describes the heart-rate zones during an activity.
|
||||
type ActivityHrZones struct {
|
||||
TimeInZone time.Duration `json:"secsInZone"`
|
||||
ZoneLowBoundary int `json:"zoneLowBoundary"`
|
||||
ZoneNumber int `json:"zoneNumber"`
|
||||
}
|
||||
|
||||
// ActivityHrZones returns the reported heart-rate zones for an activity.
|
||||
func (c *Client) ActivityHrZones(activityID int) ([]ActivityHrZones, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/activity-service/activity/%d/hrTimeInZones",
|
||||
activityID,
|
||||
)
|
||||
|
||||
var proxy []struct {
|
||||
TimeInZone float64 `json:"secsInZone"`
|
||||
ZoneLowBoundary int `json:"zoneLowBoundary"`
|
||||
ZoneNumber int `json:"zoneNumber"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
zones := make([]ActivityHrZones, len(proxy))
|
||||
|
||||
for i, p := range proxy {
|
||||
zones[i].TimeInZone = time.Duration(p.TimeInZone * float64(time.Second))
|
||||
zones[i].ZoneLowBoundary = p.ZoneLowBoundary
|
||||
zones[i].ZoneNumber = p.ZoneNumber
|
||||
}
|
||||
|
||||
return zones, nil
|
||||
}
|
||||
34
python-garmin-connect/ActivityWeather.go
Normal file
34
python-garmin-connect/ActivityWeather.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ActivityWeather describes the weather during an activity.
|
||||
type ActivityWeather struct {
|
||||
Temperature int `json:"temp"`
|
||||
ApparentTemperature int `json:"apparentTemp"`
|
||||
DewPoint int `json:"dewPoint"`
|
||||
RelativeHumidity int `json:"relativeHumidity"`
|
||||
WindDirection int `json:"windDirection"`
|
||||
WindDirectionCompassPoint string `json:"windDirectionCompassPoint"`
|
||||
WindSpeed int `json:"windSpeed"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
}
|
||||
|
||||
// ActivityWeather returns the reported weather for an activity.
|
||||
func (c *Client) ActivityWeather(activityID int) (*ActivityWeather, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/weather-service/weather/%d",
|
||||
activityID,
|
||||
)
|
||||
|
||||
weather := new(ActivityWeather)
|
||||
|
||||
err := c.getJSON(URL, weather)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return weather, nil
|
||||
}
|
||||
108
python-garmin-connect/AdhocChallenge.go
Normal file
108
python-garmin-connect/AdhocChallenge.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Player represents a participant in a challenge.
|
||||
type Player struct {
|
||||
UserProfileID int `json:"userProfileId"`
|
||||
TotalNumber float64 `json:"totalNumber"`
|
||||
LastSyncTime Time `json:"lastSyncTime"`
|
||||
Ranking int `json:"ranking"`
|
||||
ProfileImageURLSmall string `json:"profileImageSmall"`
|
||||
ProfileImageURLMedium string `json:"profileImageMedium"`
|
||||
FullName string `json:"fullName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ProUser bool `json:"isProUser"`
|
||||
TodayNumber float64 `json:"todayNumber"`
|
||||
AcceptedChallenge bool `json:"isAcceptedChallenge"`
|
||||
}
|
||||
|
||||
// AdhocChallenge is a user-initiated challenge between 2 or more participants.
|
||||
type AdhocChallenge struct {
|
||||
SocialChallengeStatusID int `json:"socialChallengeStatusId"`
|
||||
SocialChallengeActivityTypeID int `json:"socialChallengeActivityTypeId"`
|
||||
SocialChallengeType int `json:"socialChallengeType"`
|
||||
Name string `json:"adHocChallengeName"`
|
||||
Description string `json:"adHocChallengeDesc"`
|
||||
OwnerProfileID int `json:"ownerUserProfileId"`
|
||||
UUID string `json:"uuid"`
|
||||
Start Time `json:"startDate"`
|
||||
End Time `json:"endDate"`
|
||||
DurationTypeID int `json:"durationTypeId"`
|
||||
UserRanking int `json:"userRanking"`
|
||||
Players []Player `json:"players"`
|
||||
}
|
||||
|
||||
// AdhocChallenges will list the currently non-completed Ad-Hoc challenges.
|
||||
// Please note that Players will not be populated, use AdhocChallenge() to
|
||||
// retrieve players for a challenge.
|
||||
func (c *Client) AdhocChallenges() ([]AdhocChallenge, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/nonCompleted"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
challenges := make([]AdhocChallenge, 0, 10)
|
||||
|
||||
err := c.getJSON(URL, &challenges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// HistoricalAdhocChallenges will retrieve the list of completed ad-hoc
|
||||
// challenges.
|
||||
func (c *Client) HistoricalAdhocChallenges() ([]AdhocChallenge, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/historical"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
challenges := make([]AdhocChallenge, 0, 100)
|
||||
|
||||
err := c.getJSON(URL, &challenges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// AdhocChallenge will retrieve details for challenge with uuid.
|
||||
func (c *Client) AdhocChallenge(uuid string) (*AdhocChallenge, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/%s", uuid)
|
||||
|
||||
challenge := new(AdhocChallenge)
|
||||
|
||||
err := c.getJSON(URL, challenge)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
// LeaveAdhocChallenge will leave an ad-hoc challenge. If profileID is 0, the
|
||||
// currently authenticated user will be used.
|
||||
func (c *Client) LeaveAdhocChallenge(challengeUUID string, profileID int64) error {
|
||||
if profileID == 0 && c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if profileID == 0 && c.Profile != nil {
|
||||
profileID = c.Profile.ProfileID
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/%s/player/%d",
|
||||
challengeUUID,
|
||||
profileID,
|
||||
)
|
||||
|
||||
return c.write("DELETE", URL, nil, 0)
|
||||
}
|
||||
63
python-garmin-connect/AdhocChallengeInvitation.go
Normal file
63
python-garmin-connect/AdhocChallengeInvitation.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// AdhocChallengeInvitation is a ad-hoc challenge invitation.
|
||||
type AdhocChallengeInvitation struct {
|
||||
AdhocChallenge `json:",inline"`
|
||||
|
||||
UUID string `json:"adHocChallengeUuid"`
|
||||
InviteID int `json:"adHocChallengeInviteId"`
|
||||
InvitorName string `json:"invitorName"`
|
||||
InvitorID int `json:"invitorId"`
|
||||
InvitorDisplayName string `json:"invitorDisplayName"`
|
||||
InviteeID int `json:"inviteeId"`
|
||||
UserImageURL string `json:"userImageUrl"`
|
||||
}
|
||||
|
||||
// AdhocChallengeInvites list Ad-Hoc challenges awaiting response.
|
||||
func (c *Client) AdhocChallengeInvites() ([]AdhocChallengeInvitation, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/invite"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
challenges := make([]AdhocChallengeInvitation, 0, 10)
|
||||
|
||||
err := c.getJSON(URL, &challenges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Make sure the embedded UUID matches in case the user uses the embedded
|
||||
// AdhocChallenge for something.
|
||||
for i := range challenges {
|
||||
challenges[i].AdhocChallenge.UUID = challenges[i].UUID
|
||||
}
|
||||
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// AdhocChallengeInvitationRespond will respond to a ad-hoc challenge. If
|
||||
// accept is false, the challenge will be declined.
|
||||
func (c *Client) AdhocChallengeInvitationRespond(inviteID int, accept bool) error {
|
||||
scope := "decline"
|
||||
if accept {
|
||||
scope = "accept"
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/adhocchallenge-service/adHocChallenge/invite/%d/%s", inviteID, scope)
|
||||
|
||||
payload := struct {
|
||||
InviteID int `json:"inviteId"`
|
||||
Scope string `json:"scope"`
|
||||
}{
|
||||
inviteID,
|
||||
scope,
|
||||
}
|
||||
|
||||
return c.write("PUT", URL, payload, 0)
|
||||
}
|
||||
59
python-garmin-connect/Badge.go
Normal file
59
python-garmin-connect/Badge.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Badge describes a badge.
|
||||
type Badge struct {
|
||||
ID int `json:"badgeId"`
|
||||
Key string `json:"badgeKey"`
|
||||
Name string `json:"badgeName"`
|
||||
CategoryID int `json:"badgeCategoryId"`
|
||||
DifficultyID int `json:"badgeDifficultyId"`
|
||||
Points int `json:"badgePoints"`
|
||||
TypeID []int `json:"badgeTypeIds"`
|
||||
SeriesID int `json:"badgeSeriesId"`
|
||||
Start Time `json:"badgeStartDate"`
|
||||
End Time `json:"badgeEndDate"`
|
||||
UserProfileID int `json:"userProfileId"`
|
||||
FullName string `json:"fullName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
EarnedDate Time `json:"badgeEarnedDate"`
|
||||
EarnedNumber int `json:"badgeEarnedNumber"`
|
||||
Viewed bool `json:"badgeIsViewed"`
|
||||
Progress float64 `json:"badgeProgressValue"`
|
||||
Target float64 `json:"badgeTargetValue"`
|
||||
UnitID int `json:"badgeUnitId"`
|
||||
BadgeAssocTypeID int `json:"badgeAssocTypeId"`
|
||||
BadgeAssocDataID string `json:"badgeAssocDataId"`
|
||||
BadgeAssocDataName string `json:"badgeAssocDataName"`
|
||||
EarnedByMe bool `json:"earnedByMe"`
|
||||
RelatedBadges []Badge `json:"relatedBadges"`
|
||||
Connections []Badge `json:"connections"`
|
||||
}
|
||||
|
||||
// BadgeDetail will return details about a badge.
|
||||
func (c *Client) BadgeDetail(badgeID int) (*Badge, error) {
|
||||
// Alternative URL:
|
||||
// https://connect.garmin.com/modern/proxy/badge-service/badge/DISPLAYNAME/earned/detail/BADGEID
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/badge-service/badge/detail/v2/%d",
|
||||
badgeID)
|
||||
|
||||
badge := new(Badge)
|
||||
|
||||
err := c.getJSON(URL, badge)
|
||||
|
||||
// This is interesting. Garmin returns 400 if an unknown badge is
|
||||
// requested. We have no way of detecting that, so we silently changes
|
||||
// the error to ErrNotFound.
|
||||
if err == ErrBadRequest {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return badge, nil
|
||||
}
|
||||
52
python-garmin-connect/BadgeAttributes.go
Normal file
52
python-garmin-connect/BadgeAttributes.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package connect
|
||||
|
||||
// Everything from https://connect.garmin.com/modern/proxy/badge-service/badge/attributes
|
||||
|
||||
type BadgeType struct {
|
||||
ID int `json:"badgeTypeId"`
|
||||
Key string `json:"badgeTypeKey"`
|
||||
}
|
||||
|
||||
type BadgeCategory struct {
|
||||
ID int `json:"badgeCategoryId"`
|
||||
Key string `json:"badgeCategoryKey"`
|
||||
}
|
||||
|
||||
type BadgeDifficulty struct {
|
||||
ID int `json:"badgeDifficultyId"`
|
||||
Key string `json:"badgeDifficultyKey"`
|
||||
Points int `json:"badgePoints"`
|
||||
}
|
||||
|
||||
type BadgeUnit struct {
|
||||
ID int `json:"badgeUnitId"`
|
||||
Key string `json:"badgeUnitKey"`
|
||||
}
|
||||
|
||||
type BadgeAssocType struct {
|
||||
ID int `json:"badgeAssocTypeId"`
|
||||
Key string `json:"badgeAssocTypeKey"`
|
||||
}
|
||||
|
||||
type BadgeAttributes struct {
|
||||
BadgeTypes []BadgeType `json:"badgeTypes"`
|
||||
BadgeCategories []BadgeCategory `json:"badgeCategories"`
|
||||
BadgeDifficulties []BadgeDifficulty `json:"badgeDifficulties"`
|
||||
BadgeUnits []BadgeUnit `json:"badgeUnits"`
|
||||
BadgeAssocTypes []BadgeAssocType `json:"badgeAssocTypes"`
|
||||
}
|
||||
|
||||
// BadgeAttributes retrieves a list of badge attributes. At time of writing
|
||||
// we're not sure how these can be utilized.
|
||||
func (c *Client) BadgeAttributes() (*BadgeAttributes, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/badge-service/badge/attributes"
|
||||
|
||||
attributes := new(BadgeAttributes)
|
||||
|
||||
err := c.getJSON(URL, &attributes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return attributes, nil
|
||||
}
|
||||
94
python-garmin-connect/BadgeStatus.go
Normal file
94
python-garmin-connect/BadgeStatus.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package connect
|
||||
|
||||
// BadgeStatus is the badge status for a Connect user.
|
||||
type BadgeStatus struct {
|
||||
ProfileID int `json:"userProfileId"`
|
||||
Fullname string `json:"fullName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
ProUser bool `json:"userPro"`
|
||||
ProfileImageURLLarge string `json:"profileImageUrlLarge"`
|
||||
ProfileImageURLMedium string `json:"profileImageUrlMedium"`
|
||||
ProfileImageURLSmall string `json:"profileImageUrlSmall"`
|
||||
Level int `json:"userLevel"`
|
||||
LevelUpdateTime Time `json:"levelUpdateDate"`
|
||||
Point int `json:"userPoint"`
|
||||
Badges []Badge `json:"badges"`
|
||||
}
|
||||
|
||||
// BadgeLeaderBoard returns the leaderboard for points for the currently
|
||||
// authenticated user.
|
||||
func (c *Client) BadgeLeaderBoard() ([]BadgeStatus, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/badge-service/badge/leaderboard"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy struct {
|
||||
LeaderBoad []BadgeStatus `json:"connections"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxy.LeaderBoad, nil
|
||||
}
|
||||
|
||||
// BadgeCompare will compare the earned badges of the currently authenticated user against displayName.
|
||||
func (c *Client) BadgeCompare(displayName string) (*BadgeStatus, *BadgeStatus, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/badge-service/badge/compare/" + displayName
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy struct {
|
||||
User *BadgeStatus `json:"user"`
|
||||
Connection *BadgeStatus `json:"connection"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return proxy.User, proxy.Connection, nil
|
||||
}
|
||||
|
||||
// BadgesEarned will return the list of badges earned by the curently
|
||||
// authenticated user.
|
||||
func (c *Client) BadgesEarned() ([]Badge, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/badge-service/badge/earned"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
badges := make([]Badge, 0, 200)
|
||||
err := c.getJSON(URL, &badges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return badges, nil
|
||||
}
|
||||
|
||||
// BadgesAvailable will return the list of badges not yet earned by the curently
|
||||
// authenticated user.
|
||||
func (c *Client) BadgesAvailable() ([]Badge, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/badge-service/badge/available"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
badges := make([]Badge, 0, 200)
|
||||
err := c.getJSON(URL, &badges)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return badges, nil
|
||||
}
|
||||
111
python-garmin-connect/Calendar.go
Normal file
111
python-garmin-connect/Calendar.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// CalendarYear describes a Garmin Connect calendar year
|
||||
type CalendarYear struct {
|
||||
StartDayOfJanuary int `json:"startDayofJanuary"`
|
||||
LeapYear bool `json:"leapYear"`
|
||||
YearItems []YearItem `json:"yearItems"`
|
||||
YearSummaries []YearSummary `json:"yearSummaries"`
|
||||
}
|
||||
|
||||
// YearItem describes an item on a Garmin Connect calendar year
|
||||
type YearItem struct {
|
||||
Date Date `json:"date"`
|
||||
Display int `json:"display"`
|
||||
}
|
||||
|
||||
// YearSummary describes a per-activity-type yearly summary on a Garmin Connect calendar year
|
||||
type YearSummary struct {
|
||||
ActivityTypeID int `json:"activityTypeId"`
|
||||
NumberOfActivities int `json:"numberOfActivities"`
|
||||
TotalDistance int `json:"totalDistance"`
|
||||
TotalDuration int `json:"totalDuration"`
|
||||
TotalCalories int `json:"totalCalories"`
|
||||
}
|
||||
|
||||
// CalendarMonth describes a Garmin Conenct calendar month
|
||||
type CalendarMonth struct {
|
||||
StartDayOfMonth int `json:"startDayOfMonth"`
|
||||
NumOfDaysInMonth int `json:"numOfDaysInMonth"`
|
||||
NumOfDaysInPrevMonth int `json:"numOfDaysInPrevMonth"`
|
||||
Month int `json:"month"`
|
||||
Year int `json:"year"`
|
||||
CalendarItems []CalendarItem `json:"calendarItems"`
|
||||
}
|
||||
|
||||
// CalendarWeek describes a Garmin Connect calendar week
|
||||
type CalendarWeek struct {
|
||||
StartDate Date `json:"startDate"`
|
||||
EndDate Date `json:"endDate"`
|
||||
NumOfDaysInMonth int `json:"numOfDaysInMonth"`
|
||||
CalendarItems []CalendarItem `json:"calendarItems"`
|
||||
}
|
||||
|
||||
// CalendarItem describes an activity displayed on a Garmin Connect calendar
|
||||
type CalendarItem struct {
|
||||
ID int `json:"id"`
|
||||
ItemType string `json:"itemType"`
|
||||
ActivityTypeID int `json:"activityTypeId"`
|
||||
Title string `json:"title"`
|
||||
Date Date `json:"date"`
|
||||
Duration int `json:"duration"`
|
||||
Distance int `json:"distance"`
|
||||
Calories int `json:"calories"`
|
||||
StartTimestampLocal Time `json:"startTimestampLocal"`
|
||||
ElapsedDuration float64 `json:"elapsedDuration"`
|
||||
Strokes float64 `json:"strokes"`
|
||||
MaxSpeed float64 `json:"maxSpeed"`
|
||||
ShareableEvent bool `json:"shareableEvent"`
|
||||
AutoCalcCalories bool `json:"autoCalcCalories"`
|
||||
ProtectedWorkoutSchedule bool `json:"protectedWorkoutSchedule"`
|
||||
IsParent bool `json:"isParent"`
|
||||
}
|
||||
|
||||
// CalendarYear will get the activity summaries and list of days active for a given year
|
||||
func (c *Client) CalendarYear(year int) (*CalendarYear, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/calendar-service/year/%d",
|
||||
year,
|
||||
)
|
||||
calendarYear := new(CalendarYear)
|
||||
err := c.getJSON(URL, &calendarYear)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return calendarYear, nil
|
||||
}
|
||||
|
||||
// CalendarMonth will get the activities for a given month
|
||||
func (c *Client) CalendarMonth(year int, month int) (*CalendarMonth, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/calendar-service/year/%d/month/%d",
|
||||
year,
|
||||
month-1, // Months in Garmin Connect start from zero
|
||||
)
|
||||
calendarMonth := new(CalendarMonth)
|
||||
err := c.getJSON(URL, &calendarMonth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return calendarMonth, nil
|
||||
}
|
||||
|
||||
// CalendarWeek will get the activities for a given week. A week will be returned that contains the day requested, not starting with)
|
||||
func (c *Client) CalendarWeek(year int, month int, week int) (*CalendarWeek, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/calendar-service/year/%d/month/%d/day/%d/start/1",
|
||||
year,
|
||||
month-1, // Months in Garmin Connect start from zero
|
||||
week,
|
||||
)
|
||||
calendarWeek := new(CalendarWeek)
|
||||
err := c.getJSON(URL, &calendarWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return calendarWeek, nil
|
||||
}
|
||||
615
python-garmin-connect/Client.go
Normal file
615
python-garmin-connect/Client.go
Normal file
@@ -0,0 +1,615 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrForbidden will be returned if the client doesn't have access to the
|
||||
// requested ressource.
|
||||
ErrForbidden = Error("forbidden")
|
||||
|
||||
// ErrNotFound will be returned if the requested ressource could not be
|
||||
// found.
|
||||
ErrNotFound = Error("not found")
|
||||
|
||||
// ErrBadRequest will be returned if Garmin returned a status code 400.
|
||||
ErrBadRequest = Error("bad request")
|
||||
|
||||
// ErrNoCredentials will be returned if credentials are needed - but none
|
||||
// are set.
|
||||
ErrNoCredentials = Error("no credentials set")
|
||||
|
||||
// ErrNotAuthenticated will be returned is the client is not
|
||||
// authenticated as required by the request. Remember to call
|
||||
// Authenticate().
|
||||
ErrNotAuthenticated = Error("client is not authenticated")
|
||||
|
||||
// ErrWrongCredentials will be returned if the username and/or
|
||||
// password is not recognized by Garmin Connect.
|
||||
ErrWrongCredentials = Error("username and/or password not recognized")
|
||||
)
|
||||
|
||||
const (
|
||||
// sessionCookieName is the magic session cookie name.
|
||||
sessionCookieName = "SESSIONID"
|
||||
|
||||
// cflbCookieName is the cookie used by Cloudflare to pin the request
|
||||
// to a specific backend.
|
||||
cflbCookieName = "__cflb"
|
||||
)
|
||||
|
||||
// Client can be used to access the unofficial Garmin Connect API.
|
||||
type Client struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
SessionID string `json:"sessionID"`
|
||||
Profile *SocialProfile `json:"socialProfile"`
|
||||
|
||||
// LoadBalancerID is the load balancer ID set by Cloudflare in front of
|
||||
// Garmin Connect. This must be preserves across requests. A session key
|
||||
// is only valid with a corresponding loadbalancer key.
|
||||
LoadBalancerID string `json:"cflb"`
|
||||
|
||||
client *http.Client
|
||||
autoRenewSession bool
|
||||
debugLogger Logger
|
||||
dumpWriter io.Writer
|
||||
}
|
||||
|
||||
// Option is the type to set options on the client.
|
||||
type Option func(*Client)
|
||||
|
||||
// SessionID will set a predefined session ID. This can be useful for clients
|
||||
// keeping state. A few HTTP roundtrips can be saved, if the session ID is
|
||||
// reused. And some load would be taken of Garmin servers. This must be
|
||||
// accompanied by LoadBalancerID.
|
||||
// Generally this should not be used. Users of this package should save
|
||||
// all exported fields from Client and re-use those at a later request.
|
||||
// json.Marshal() and json.Unmarshal() can be used.
|
||||
func SessionID(sessionID string) Option {
|
||||
return func(c *Client) {
|
||||
c.SessionID = sessionID
|
||||
}
|
||||
}
|
||||
|
||||
// LoadBalancerID will set a load balancer ID. This is used by Garmin load
|
||||
// balancers to route subsequent requests to the same backend server.
|
||||
func LoadBalancerID(loadBalancerID string) Option {
|
||||
return func(c *Client) {
|
||||
c.LoadBalancerID = loadBalancerID
|
||||
}
|
||||
}
|
||||
|
||||
// Credentials can be used to pass login credentials to NewClient.
|
||||
func Credentials(email string, password string) Option {
|
||||
return func(c *Client) {
|
||||
c.Email = email
|
||||
c.Password = password
|
||||
}
|
||||
}
|
||||
|
||||
// AutoRenewSession will set if the session should be autorenewed upon expire.
|
||||
// Default is true.
|
||||
func AutoRenewSession(autoRenew bool) Option {
|
||||
return func(c *Client) {
|
||||
c.autoRenewSession = autoRenew
|
||||
}
|
||||
}
|
||||
|
||||
// DebugLogger is used to set a debug logger.
|
||||
func DebugLogger(logger Logger) Option {
|
||||
return func(c *Client) {
|
||||
c.debugLogger = logger
|
||||
}
|
||||
}
|
||||
|
||||
// DumpWriter will instruct Client to dump all HTTP requests and responses to
|
||||
// and from Garmin to w.
|
||||
func DumpWriter(w io.Writer) Option {
|
||||
return func(c *Client) {
|
||||
c.dumpWriter = w
|
||||
}
|
||||
}
|
||||
|
||||
// NewClient returns a new client for accessing the unofficial Garmin Connect
|
||||
// API.
|
||||
func NewClient(options ...Option) *Client {
|
||||
client := &Client{
|
||||
client: &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
// To avoid a Cloudflare error, we have to use TLS 1.1 or 1.2.
|
||||
MinVersion: tls.VersionTLS11,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
},
|
||||
autoRenewSession: true,
|
||||
debugLogger: &discardLog{},
|
||||
dumpWriter: nil,
|
||||
}
|
||||
|
||||
client.SetOptions(options...)
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// SetOptions can be used to set various options on Client.
|
||||
func (c *Client) SetOptions(options ...Option) {
|
||||
for _, option := range options {
|
||||
option(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dump(reqResp interface{}) {
|
||||
if c.dumpWriter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var dump []byte
|
||||
switch obj := reqResp.(type) {
|
||||
case *http.Request:
|
||||
_, _ = c.dumpWriter.Write([]byte("\n\nREQUEST\n"))
|
||||
dump, _ = httputil.DumpRequestOut(obj, true)
|
||||
case *http.Response:
|
||||
_, _ = c.dumpWriter.Write([]byte("\n\nRESPONSE\n"))
|
||||
dump, _ = httputil.DumpResponse(obj, true)
|
||||
default:
|
||||
panic("unsupported type")
|
||||
}
|
||||
|
||||
_, _ = c.dumpWriter.Write(dump)
|
||||
}
|
||||
|
||||
// addCookies adds needed cookies to a http request if the values are known.
|
||||
func (c *Client) addCookies(req *http.Request) {
|
||||
if c.SessionID != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Value: c.SessionID,
|
||||
Name: sessionCookieName,
|
||||
})
|
||||
}
|
||||
|
||||
if c.LoadBalancerID != "" {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Value: c.LoadBalancerID,
|
||||
Name: cflbCookieName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) newRequest(method string, url string, body io.Reader) (*http.Request, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Play nice and give Garmin engineers a way to contact us.
|
||||
req.Header.Set("User-Agent", "github.com/abrander/garmin-connect")
|
||||
|
||||
// Yep. This is needed for requests sent to the API. No idea what it does.
|
||||
req.Header.Add("nk", "NT")
|
||||
|
||||
c.addCookies(req)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (c *Client) getJSON(url string, target interface{}) error {
|
||||
req, err := c.newRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
return decoder.Decode(target)
|
||||
}
|
||||
|
||||
// write is suited for writing stuff to the API when you're NOT expected any
|
||||
// data in return but a HTTP status code.
|
||||
func (c *Client) write(method string, url string, payload interface{}, expectedStatus int) error {
|
||||
var body io.Reader
|
||||
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
|
||||
req, err := c.newRequest(method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If we have a payload it is by definition JSON.
|
||||
if payload != nil {
|
||||
req.Header.Add("content-type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if expectedStatus > 0 && resp.StatusCode != expectedStatus {
|
||||
return fmt.Errorf("HTTP %s returned %d (%d expected)", method, resp.StatusCode, expectedStatus)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleForbidden will try to extract an error message from the response.
|
||||
func (c *Client) handleForbidden(resp *http.Response) error {
|
||||
defer resp.Body.Close()
|
||||
|
||||
type proxy struct {
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var errorMessage proxy
|
||||
|
||||
err := decoder.Decode(&errorMessage)
|
||||
if err == nil && errorMessage.Message != "" {
|
||||
return Error(errorMessage.Message)
|
||||
}
|
||||
|
||||
return ErrForbidden
|
||||
}
|
||||
|
||||
func (c *Client) do(req *http.Request) (*http.Response, error) {
|
||||
c.debugLogger.Printf("Requesting %s at %s", req.Method, req.URL.String())
|
||||
|
||||
// Save the body in case we need to replay the request.
|
||||
var save io.ReadCloser
|
||||
var err error
|
||||
if req.Body != nil {
|
||||
save, req.Body, err = drainBody(req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
c.dump(req)
|
||||
t0 := time.Now()
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.dump(resp)
|
||||
|
||||
// This is exciting. If the user does not have permission to access a
|
||||
// ressource, the API will return an ApplicationException and return a
|
||||
// 403 status code.
|
||||
// If the session is invalid, the Garmin API will return the same exception
|
||||
// and status code (!).
|
||||
// To distinguish between these two error cases, we look for a new session
|
||||
// cookie in the response. If a new session cookies is set by Garmin, we
|
||||
// assume our current session is invalid.
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == sessionCookieName {
|
||||
resp.Body.Close()
|
||||
c.debugLogger.Printf("Session invalid, requesting new session")
|
||||
|
||||
// Wups. Our session got invalidated.
|
||||
c.SetOptions(SessionID(""))
|
||||
c.SetOptions(LoadBalancerID(""))
|
||||
|
||||
// Re-new session.
|
||||
err = c.Authenticate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.debugLogger.Printf("Successfully authenticated as %s", c.Email)
|
||||
|
||||
// Replace the drained body
|
||||
req.Body = save
|
||||
|
||||
// Replace the cookie ned newRequest with the new sessionid and load balancer key.
|
||||
req.Header.Del("Cookie")
|
||||
c.addCookies(req)
|
||||
|
||||
c.debugLogger.Printf("Replaying %s request to %s", req.Method, req.URL.String())
|
||||
|
||||
c.dump(req)
|
||||
|
||||
// Replay the original request only once, if we fail twice
|
||||
// something is rotten, and we should give up.
|
||||
t0 = time.Now()
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.dump(resp)
|
||||
}
|
||||
}
|
||||
|
||||
c.debugLogger.Printf("Got HTTP status code %d in %s", resp.StatusCode, time.Since(t0).String())
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusBadRequest:
|
||||
resp.Body.Close()
|
||||
return nil, ErrBadRequest
|
||||
case http.StatusForbidden:
|
||||
return nil, c.handleForbidden(resp)
|
||||
case http.StatusNotFound:
|
||||
resp.Body.Close()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// Download will retrieve a file from url using Garmin Connect credentials.
|
||||
// It's mostly useful when developing new features or debugging existing
|
||||
// ones.
|
||||
// Please note that this will pass the Garmin session cookie to the URL
|
||||
// provided. Only use this for endpoints on garmin.com.
|
||||
func (c *Client) Download(url string, w io.Writer) error {
|
||||
req, err := c.newRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, err = io.Copy(w, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) authenticated() bool {
|
||||
return c.SessionID != ""
|
||||
}
|
||||
|
||||
// Authenticate using a Garmin Connect username and password provided by
|
||||
// the Credentials option function.
|
||||
func (c *Client) Authenticate() error {
|
||||
// We cannot use Client.do() in this function, since this function can be
|
||||
// called from do() upon session renewal.
|
||||
URL := "https://sso.garmin.com/sso/signin" +
|
||||
"?service=https%3A%2F%2Fconnect.garmin.com%2Fmodern%2F" +
|
||||
"&gauthHost=https%3A%2F%2Fconnect.garmin.com%2Fmodern%2F" +
|
||||
"&generateExtraServiceTicket=true" +
|
||||
"&generateTwoExtraServiceTickets=true"
|
||||
|
||||
if c.Email == "" || c.Password == "" {
|
||||
return ErrNoCredentials
|
||||
}
|
||||
|
||||
c.debugLogger.Printf("Getting CSRF token at %s", URL)
|
||||
|
||||
// Start by getting CSRF token.
|
||||
req, err := http.NewRequest("GET", URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dump(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dump(resp)
|
||||
|
||||
csrfToken, err := extractCSRFToken(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
c.debugLogger.Printf("Got CSRF token: '%s'", csrfToken)
|
||||
|
||||
c.debugLogger.Printf("Trying credentials at %s", URL)
|
||||
|
||||
formValues := url.Values{
|
||||
"username": {c.Email},
|
||||
"password": {c.Password},
|
||||
"embed": {"false"},
|
||||
"_csrf": {csrfToken},
|
||||
}
|
||||
|
||||
req, err = c.newRequest("POST", URL, strings.NewReader(formValues.Encode()))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Referer", URL)
|
||||
|
||||
c.dump(req)
|
||||
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dump(resp)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
|
||||
return fmt.Errorf("Garmin SSO returned \"%s\"", resp.Status)
|
||||
}
|
||||
|
||||
body, _ := ioutil.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
// Extract ticket URL
|
||||
t := regexp.MustCompile(`https:\\\/\\\/connect.garmin.com\\\/modern\\\/\?ticket=(([a-zA-Z0-9]|-)*)`)
|
||||
ticketURL := t.FindString(string(body))
|
||||
|
||||
// undo escaping
|
||||
ticketURL = strings.Replace(ticketURL, "\\/", "/", -1)
|
||||
|
||||
if ticketURL == "" {
|
||||
return ErrWrongCredentials
|
||||
}
|
||||
|
||||
c.debugLogger.Printf("Requesting session at ticket URL %s", ticketURL)
|
||||
|
||||
// Use ticket to request session.
|
||||
req, _ = c.newRequest("GET", ticketURL, nil)
|
||||
c.dump(req)
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dump(resp)
|
||||
resp.Body.Close()
|
||||
|
||||
// Look for the needed sessionid cookie.
|
||||
for _, cookie := range resp.Cookies() {
|
||||
if cookie.Name == cflbCookieName {
|
||||
c.debugLogger.Printf("Found load balancer cookie with value %s", cookie.Value)
|
||||
|
||||
c.SetOptions(LoadBalancerID(cookie.Value))
|
||||
}
|
||||
|
||||
if cookie.Name == sessionCookieName {
|
||||
c.debugLogger.Printf("Found session cookie with value %s", cookie.Value)
|
||||
|
||||
c.SetOptions(SessionID(cookie.Value))
|
||||
}
|
||||
}
|
||||
|
||||
if c.SessionID == "" {
|
||||
c.debugLogger.Printf("No sessionid found")
|
||||
|
||||
return ErrWrongCredentials
|
||||
}
|
||||
|
||||
// The session id will not be valid until we redeem the sessions by
|
||||
// following the redirect.
|
||||
location := resp.Header.Get("Location")
|
||||
c.debugLogger.Printf("Redeeming session id at %s", location)
|
||||
|
||||
req, _ = c.newRequest("GET", location, nil)
|
||||
c.dump(req)
|
||||
resp, err = c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.dump(resp)
|
||||
|
||||
c.Profile, err = extractSocialProfile(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp.Body.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractSocialProfile will try to extract the social profile from the HTML.
|
||||
// This is very fragile.
|
||||
func extractSocialProfile(body io.Reader) (*SocialProfile, error) {
|
||||
scanner := bufio.NewScanner(body)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "VIEWER_SOCIAL_PROFILE") {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.Replace(line, "\\", "", -1)
|
||||
line = strings.TrimPrefix(line, "window.VIEWER_SOCIAL_PROFILE = ")
|
||||
line = strings.TrimSuffix(line, ";")
|
||||
|
||||
profile := new(SocialProfile)
|
||||
|
||||
err := json.Unmarshal([]byte(line), profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("social profile not found in HTML")
|
||||
}
|
||||
|
||||
// extractCSRFToken will try to extract the CSRF token from the signin form.
|
||||
// This is very fragile. Maybe we should replace this madness by a real HTML
|
||||
// parser some day.
|
||||
func extractCSRFToken(body io.Reader) (string, error) {
|
||||
scanner := bufio.NewScanner(body)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if strings.Contains(line, "name=\"_csrf\"") {
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimPrefix(line, `<input type="hidden" name="_csrf" value="`)
|
||||
line = strings.TrimSuffix(line, `" />`)
|
||||
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", errors.New("CSRF token not found")
|
||||
}
|
||||
|
||||
// Signout will end the session with Garmin. If you use this for regular
|
||||
// automated tasks, it would be nice to signout each time to avoid filling
|
||||
// Garmin's session tables with a lot of short-lived sessions.
|
||||
func (c *Client) Signout() error {
|
||||
if !c.authenticated() {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
req, err := c.newRequest("GET", "https://connect.garmin.com/modern/auth/logout", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.SessionID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
c.SetOptions(SessionID(""))
|
||||
c.SetOptions(LoadBalancerID(""))
|
||||
|
||||
return nil
|
||||
}
|
||||
111
python-garmin-connect/Connections.go
Normal file
111
python-garmin-connect/Connections.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Connections will list the connections of displayName. If displayName is
|
||||
// empty, the current authenticated users connection list wil be returned.
|
||||
func (c *Client) Connections(displayName string) ([]SocialProfile, error) {
|
||||
// There also exist an endpoint without /pagination/ but it will return
|
||||
// 403 for *some* connections.
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/socialProfile/connections/pagination/" + displayName
|
||||
|
||||
if !c.authenticated() && displayName == "" {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy struct {
|
||||
Connections []SocialProfile `json:"userConnections"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxy.Connections, nil
|
||||
}
|
||||
|
||||
// PendingConnections returns a list of pending connections.
|
||||
func (c *Client) PendingConnections() ([]SocialProfile, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/connection/pending"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
pending := make([]SocialProfile, 0, 10)
|
||||
|
||||
err := c.getJSON(URL, &pending)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
// AcceptConnection will accept a pending connection.
|
||||
func (c *Client) AcceptConnection(connectionRequestID int) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/userprofile-service/connection/accept/%d", connectionRequestID)
|
||||
payload := struct {
|
||||
ConnectionRequestID int `json:"connectionRequestId"`
|
||||
}{
|
||||
ConnectionRequestID: connectionRequestID,
|
||||
}
|
||||
|
||||
return c.write("PUT", URL, payload, 0)
|
||||
}
|
||||
|
||||
// SearchConnections can search other users of Garmin Connect.
|
||||
func (c *Client) SearchConnections(keyword string) ([]SocialProfile, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/usersearch-service/search"
|
||||
|
||||
payload := url.Values{
|
||||
"start": {"1"},
|
||||
"limit": {"20"},
|
||||
"keyword": {keyword},
|
||||
}
|
||||
|
||||
req, err := c.newRequest("POST", URL, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("content-type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var proxy struct {
|
||||
Profiles []SocialProfile `json:"profileList"`
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
err = dec.Decode(&proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxy.Profiles, nil
|
||||
}
|
||||
|
||||
// RemoveConnection will remove a connection.
|
||||
func (c *Client) RemoveConnection(connectionRequestID int) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/userprofile-service/connection/end/%d", connectionRequestID)
|
||||
|
||||
return c.write("PUT", URL, nil, 200)
|
||||
}
|
||||
|
||||
// RequestConnection will request a connection with displayName.
|
||||
func (c *Client) RequestConnection(displayName string) error {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/connection/request/" + displayName
|
||||
|
||||
return c.write("PUT", URL, nil, 0)
|
||||
}
|
||||
56
python-garmin-connect/DailyStress.go
Normal file
56
python-garmin-connect/DailyStress.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StressPoint is a measured stress level at a point in time.
|
||||
type StressPoint struct {
|
||||
Timestamp time.Time
|
||||
Value int
|
||||
}
|
||||
|
||||
// DailyStress is a stress reading for a single day.
|
||||
type DailyStress struct {
|
||||
UserProfilePK int `json:"userProfilePK"`
|
||||
CalendarDate string `json:"calendarDate"`
|
||||
StartGMT Time `json:"startTimestampGMT"`
|
||||
EndGMT Time `json:"endTimestampGMT"`
|
||||
StartLocal Time `json:"startTimestampLocal"`
|
||||
EndLocal Time `json:"endTimestampLocal"`
|
||||
Max int `json:"maxStressLevel"`
|
||||
Average int `json:"avgStressLevel"`
|
||||
Values []StressPoint
|
||||
}
|
||||
|
||||
// DailyStress will retrieve stress levels for date.
|
||||
func (c *Client) DailyStress(date time.Time) (*DailyStress, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/dailyStress/%s",
|
||||
formatDate(date))
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
// We use a proxy object to deserialize the values to proper Go types.
|
||||
var proxy struct {
|
||||
DailyStress
|
||||
StressValuesArray [][2]int64 `json:"stressValuesArray"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &proxy.DailyStress
|
||||
ret.Values = make([]StressPoint, len(proxy.StressValuesArray))
|
||||
|
||||
for i, point := range proxy.StressValuesArray {
|
||||
ret.Values[i].Timestamp = time.Unix(point[0]/1000, 0)
|
||||
ret.Values[i].Value = int(point[1])
|
||||
}
|
||||
|
||||
return &proxy.DailyStress, nil
|
||||
}
|
||||
189
python-garmin-connect/DailySummary.go
Normal file
189
python-garmin-connect/DailySummary.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateValue is a numeric value recorded on a given date.
|
||||
type DateValue struct {
|
||||
Date Date `json:"calendarDate"`
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
|
||||
// DailySummaries provides a daily summary of various statistics for multiple
|
||||
// days.
|
||||
type DailySummaries struct {
|
||||
Start time.Time `json:"statisticsStartDate"`
|
||||
End time.Time `json:"statisticsEndDate"`
|
||||
TotalSteps []DateValue `json:"WELLNESS_TOTAL_STEPS"`
|
||||
ActiveCalories []DateValue `json:"COMMON_ACTIVE_CALORIES"`
|
||||
FloorsAscended []DateValue `json:"WELLNESS_FLOORS_ASCENDED"`
|
||||
IntensityMinutes []DateValue `json:"WELLNESS_USER_INTENSITY_MINUTES_GOAL"`
|
||||
MaxHeartRate []DateValue `json:"WELLNESS_MAX_HEART_RATE"`
|
||||
MinimumAverageHeartRate []DateValue `json:"WELLNESS_MIN_AVG_HEART_RATE"`
|
||||
MinimumHeartrate []DateValue `json:"WELLNESS_MIN_HEART_RATE"`
|
||||
AverageStress []DateValue `json:"WELLNESS_AVERAGE_STRESS"`
|
||||
RestingHeartRate []DateValue `json:"WELLNESS_RESTING_HEART_RATE"`
|
||||
MaxStress []DateValue `json:"WELLNESS_MAX_STRESS"`
|
||||
AbnormalHeartRateAlers []DateValue `json:"WELLNESS_ABNORMALHR_ALERTS_COUNT"`
|
||||
MaximumAverageHeartRate []DateValue `json:"WELLNESS_MAX_AVG_HEART_RATE"`
|
||||
StepGoal []DateValue `json:"WELLNESS_TOTAL_STEP_GOAL"`
|
||||
FlorsAscendedGoal []DateValue `json:"WELLNESS_USER_FLOORS_ASCENDED_GOAL"`
|
||||
ModerateIntensityMinutes []DateValue `json:"WELLNESS_MODERATE_INTENSITY_MINUTES"`
|
||||
TotalColaries []DateValue `json:"WELLNESS_TOTAL_CALORIES"`
|
||||
BodyBatteryCharged []DateValue `json:"WELLNESS_BODYBATTERY_CHARGED"`
|
||||
FloorsDescended []DateValue `json:"WELLNESS_FLOORS_DESCENDED"`
|
||||
BMRCalories []DateValue `json:"WELLNESS_BMR_CALORIES"`
|
||||
FoodCaloriesRemainin []DateValue `json:"FOOD_CALORIES_REMAINING"`
|
||||
TotalCalories []DateValue `json:"COMMON_TOTAL_CALORIES"`
|
||||
BodyBatteryDrained []DateValue `json:"WELLNESS_BODYBATTERY_DRAINED"`
|
||||
AverageSteps []DateValue `json:"WELLNESS_AVERAGE_STEPS"`
|
||||
VigorousIntensifyMinutes []DateValue `json:"WELLNESS_VIGOROUS_INTENSITY_MINUTES"`
|
||||
WellnessDistance []DateValue `json:"WELLNESS_TOTAL_DISTANCE"`
|
||||
Distance []DateValue `json:"COMMON_TOTAL_DISTANCE"`
|
||||
WellnessActiveCalories []DateValue `json:"WELLNESS_ACTIVE_CALORIES"`
|
||||
}
|
||||
|
||||
// DailySummary is an extensive summary for a single day.
|
||||
type DailySummary struct {
|
||||
ProfileID int64 `json:"userProfileId"`
|
||||
TotalKilocalories float64 `json:"totalKilocalories"`
|
||||
ActiveKilocalories float64 `json:"activeKilocalories"`
|
||||
BMRKilocalories float64 `json:"bmrKilocalories"`
|
||||
WellnessKilocalories float64 `json:"wellnessKilocalories"`
|
||||
BurnedKilocalories float64 `json:"burnedKilocalories"`
|
||||
ConsumedKilocalories float64 `json:"consumedKilocalories"`
|
||||
RemainingKilocalories float64 `json:"remainingKilocalories"`
|
||||
TotalSteps int `json:"totalSteps"`
|
||||
NetCalorieGoal float64 `json:"netCalorieGoal"`
|
||||
TotalDistanceMeters int `json:"totalDistanceMeters"`
|
||||
WellnessDistanceMeters int `json:"wellnessDistanceMeters"`
|
||||
WellnessActiveKilocalories float64 `json:"wellnessActiveKilocalories"`
|
||||
NetRemainingKilocalories float64 `json:"netRemainingKilocalories"`
|
||||
UserID int64 `json:"userDailySummaryId"`
|
||||
Date Date `json:"calendarDate"`
|
||||
UUID string `json:"uuid"`
|
||||
StepGoal int `json:"dailyStepGoal"`
|
||||
StartTimeGMT Time `json:"wellnessStartTimeGmt"`
|
||||
EndTimeGMT Time `json:"wellnessEndTimeGmt"`
|
||||
StartLocal Time `json:"wellnessStartTimeLocal"`
|
||||
EndLocal Time `json:"wellnessEndTimeLocal"`
|
||||
Duration time.Duration `json:"durationInMilliseconds"`
|
||||
Description string `json:"wellnessDescription"`
|
||||
HighlyActive time.Duration `json:"highlyActiveSeconds"`
|
||||
Active time.Duration `json:"activeSeconds"`
|
||||
Sedentary time.Duration `json:"sedentarySeconds"`
|
||||
Sleeping time.Duration `json:"sleepingSeconds"`
|
||||
IncludesWellnessData bool `json:"includesWellnessData"`
|
||||
IncludesActivityData bool `json:"includesActivityData"`
|
||||
IncludesCalorieConsumedData bool `json:"includesCalorieConsumedData"`
|
||||
PrivacyProtected bool `json:"privacyProtected"`
|
||||
ModerateIntensity time.Duration `json:"moderateIntensityMinutes"`
|
||||
VigorousIntensity time.Duration `json:"vigorousIntensityMinutes"`
|
||||
FloorsAscendedInMeters float64 `json:"floorsAscendedInMeters"`
|
||||
FloorsDescendedInMeters float64 `json:"floorsDescendedInMeters"`
|
||||
FloorsAscended float64 `json:"floorsAscended"`
|
||||
FloorsDescended float64 `json:"floorsDescended"`
|
||||
IntensityGoal time.Duration `json:"intensityMinutesGoal"`
|
||||
FloorsAscendedGoal int `json:"userFloorsAscendedGoal"`
|
||||
MinHeartRate int `json:"minHeartRate"`
|
||||
MaxHeartRate int `json:"maxHeartRate"`
|
||||
RestingHeartRate int `json:"restingHeartRate"`
|
||||
LastSevenDaysAvgRestingHeartRate int `json:"lastSevenDaysAvgRestingHeartRate"`
|
||||
Source string `json:"source"`
|
||||
AverageStress int `json:"averageStressLevel"`
|
||||
MaxStress int `json:"maxStressLevel"`
|
||||
Stress time.Duration `json:"stressDuration"`
|
||||
RestStress time.Duration `json:"restStressDuration"`
|
||||
ActivityStress time.Duration `json:"activityStressDuration"`
|
||||
UncategorizedStress time.Duration `json:"uncategorizedStressDuration"`
|
||||
TotalStress time.Duration `json:"totalStressDuration"`
|
||||
LowStress time.Duration `json:"lowStressDuration"`
|
||||
MediumStress time.Duration `json:"mediumStressDuration"`
|
||||
HighStress time.Duration `json:"highStressDuration"`
|
||||
StressQualifier string `json:"stressQualifier"`
|
||||
MeasurableAwake time.Duration `json:"measurableAwakeDuration"`
|
||||
MeasurableAsleep time.Duration `json:"measurableAsleepDuration"`
|
||||
LastSyncGMT Time `json:"lastSyncTimestampGMT"`
|
||||
MinAverageHeartRate int `json:"minAvgHeartRate"`
|
||||
MaxAverageHeartRate int `json:"maxAvgHeartRate"`
|
||||
}
|
||||
|
||||
// DailySummary will retrieve a detailed daily summary for date. If
|
||||
// displayName is empty, the currently authenticated user will be used.
|
||||
func (c *Client) DailySummary(displayName string, date time.Time) (*DailySummary, error) {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/usersummary-service/usersummary/daily/%s?calendarDate=%s",
|
||||
displayName,
|
||||
formatDate(date),
|
||||
)
|
||||
|
||||
summary := new(DailySummary)
|
||||
|
||||
err := c.getJSON(URL, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary.Duration *= time.Millisecond
|
||||
summary.HighlyActive *= time.Second
|
||||
summary.Active *= time.Second
|
||||
summary.Sedentary *= time.Second
|
||||
summary.Sleeping *= time.Second
|
||||
summary.ModerateIntensity *= time.Minute
|
||||
summary.VigorousIntensity *= time.Minute
|
||||
summary.IntensityGoal *= time.Minute
|
||||
summary.Stress *= time.Second
|
||||
summary.RestStress *= time.Second
|
||||
summary.ActivityStress *= time.Second
|
||||
summary.UncategorizedStress *= time.Second
|
||||
summary.TotalStress *= time.Second
|
||||
summary.LowStress *= time.Second
|
||||
summary.MediumStress *= time.Second
|
||||
summary.HighStress *= time.Second
|
||||
summary.MeasurableAwake *= time.Second
|
||||
summary.MeasurableAsleep *= time.Second
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// DailySummaries will retrieve a daily summary for userID.
|
||||
func (c *Client) DailySummaries(userID string, from time.Time, until time.Time) (*DailySummaries, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/userstats-service/wellness/daily/%s?fromDate=%s&untilDate=%s",
|
||||
userID,
|
||||
formatDate(from),
|
||||
formatDate(until),
|
||||
)
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
// We use a proxy object to deserialize the values to proper Go types.
|
||||
var proxy struct {
|
||||
Start Date `json:"statisticsStartDate"`
|
||||
End Date `json:"statisticsEndDate"`
|
||||
AllMetrics struct {
|
||||
Summary DailySummaries `json:"metricsMap"`
|
||||
} `json:"allMetrics"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &proxy.AllMetrics.Summary
|
||||
ret.Start = proxy.Start.Time()
|
||||
ret.End = proxy.End.Time()
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
87
python-garmin-connect/Date.go
Normal file
87
python-garmin-connect/Date.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Date represents a single day in Garmin Connect.
|
||||
type Date struct {
|
||||
Year int
|
||||
Month time.Month
|
||||
DayOfMonth int
|
||||
}
|
||||
|
||||
// Time returns a time.Time for usage in other packages.
|
||||
func (d Date) Time() time.Time {
|
||||
return time.Date(d.Year, d.Month, d.DayOfMonth, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (d *Date) UnmarshalJSON(value []byte) error {
|
||||
if string(value) == "null" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sometimes dates are transferred as milliseconds since epoch :-/
|
||||
i, err := strconv.ParseInt(string(value), 10, 64)
|
||||
if err == nil {
|
||||
t := time.Unix(i/1000, 0)
|
||||
|
||||
d.Year, d.Month, d.DayOfMonth = t.Date()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var blip string
|
||||
err = json.Unmarshal(value, &blip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Sscanf(blip, "%04d-%02d-%02d", &d.Year, &d.Month, &d.DayOfMonth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (d Date) MarshalJSON() ([]byte, error) {
|
||||
// To better support the Garmin API we marshal the empty value as null.
|
||||
if d.Year == 0 && d.Month == 0 && d.DayOfMonth == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
|
||||
return []byte(fmt.Sprintf("\"%04d-%02d-%02d\"", d.Year, d.Month, d.DayOfMonth)), nil
|
||||
}
|
||||
|
||||
// ParseDate will parse a date in the format yyyy-mm-dd.
|
||||
func ParseDate(in string) (Date, error) {
|
||||
d := Date{}
|
||||
|
||||
_, err := fmt.Sscanf(in, "%04d-%02d-%02d", &d.Year, &d.Month, &d.DayOfMonth)
|
||||
|
||||
return d, err
|
||||
}
|
||||
|
||||
// String implements Stringer.
|
||||
func (d Date) String() string {
|
||||
if d.Year == 0 && d.Month == 0 && d.DayOfMonth == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.DayOfMonth)
|
||||
}
|
||||
|
||||
// Today will return a Date set to today.
|
||||
func Today() Date {
|
||||
d := Date{}
|
||||
|
||||
d.Year, d.Month, d.DayOfMonth = time.Now().Date()
|
||||
|
||||
return d
|
||||
}
|
||||
10
python-garmin-connect/Error.go
Normal file
10
python-garmin-connect/Error.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package connect
|
||||
|
||||
// Error is a type implementing the error interface. We use this to define
|
||||
// constant errors.
|
||||
type Error string
|
||||
|
||||
// Error implements error.
|
||||
func (e Error) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
131
python-garmin-connect/Gear.go
Normal file
131
python-garmin-connect/Gear.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Gear describes a Garmin Connect gear entry
|
||||
type Gear struct {
|
||||
Uuid string `json:"uuid"`
|
||||
GearPk int `json:"gearPk"`
|
||||
UserProfileID int64 `json:"userProfilePk"`
|
||||
GearMakeName string `json:"gearMakeName"`
|
||||
GearModelName string `json:"gearModelName"`
|
||||
GearTypeName string `json:"gearTypeName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CustomMakeModel string `json:"customMakeModel"`
|
||||
ImageNameLarge string `json:"imageNameLarge"`
|
||||
ImageNameMedium string `json:"imageNameMedium"`
|
||||
ImageNameSmall string `json:"imageNameSmall"`
|
||||
DateBegin Time `json:"dateBegin"`
|
||||
DateEnd Time `json:"dateEnd"`
|
||||
MaximumMeters float64 `json:"maximumMeters"`
|
||||
Notified bool `json:"notified"`
|
||||
CreateDate Time `json:"createDate"`
|
||||
UpdateDate Time `json:"updateDate"`
|
||||
}
|
||||
|
||||
// GearType desribes the types of gear
|
||||
type GearType struct {
|
||||
TypeID int `json:"gearTypePk"`
|
||||
TypeName string `json:"gearTypeName"`
|
||||
CreateDate Time `json:"createDate"`
|
||||
UpdateDate Time `json:"updateData"`
|
||||
}
|
||||
|
||||
// GearStats describes the stats of gear
|
||||
type GearStats struct {
|
||||
TotalDistance float64 `json:"totalDistance"`
|
||||
TotalActivities int `json:"totalActivities"`
|
||||
Processsing bool `json:"processing"`
|
||||
}
|
||||
|
||||
// Gear will retrieve the details of the users gear
|
||||
func (c *Client) Gear(profileID int64) ([]Gear, error) {
|
||||
if profileID == 0 && c.Profile == nil {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if profileID == 0 && c.Profile != nil {
|
||||
profileID = c.Profile.ProfileID
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/gear-service/gear/filterGear?userProfilePk=%d",
|
||||
profileID,
|
||||
)
|
||||
var gear []Gear
|
||||
err := c.getJSON(URL, &gear)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gear, nil
|
||||
}
|
||||
|
||||
// GearType will list the gear types
|
||||
func (c *Client) GearType() ([]GearType, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/gear-service/gear/types"
|
||||
var gearType []GearType
|
||||
err := c.getJSON(URL, &gearType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gearType, nil
|
||||
}
|
||||
|
||||
// GearStats will get the statistics of an item of gear, given the uuid
|
||||
func (c *Client) GearStats(uuid string) (*GearStats, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/userstats-service/gears/%s",
|
||||
uuid,
|
||||
)
|
||||
gearStats := new(GearStats)
|
||||
err := c.getJSON(URL, &gearStats)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gearStats, nil
|
||||
}
|
||||
|
||||
// GearLink will link an item of gear to an activity. Multiple items of gear can be linked.
|
||||
func (c *Client) GearLink(uuid string, activityID int) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/gear-service/gear/link/%s/activity/%d",
|
||||
uuid,
|
||||
activityID,
|
||||
)
|
||||
|
||||
return c.write("PUT", URL, "", 200)
|
||||
}
|
||||
|
||||
// GearUnlink will remove an item of gear from an activity. All items of gear can be unlinked.
|
||||
func (c *Client) GearUnlink(uuid string, activityID int) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/gear-service/gear/unlink/%s/activity/%d",
|
||||
uuid,
|
||||
activityID,
|
||||
)
|
||||
|
||||
return c.write("PUT", URL, "", 200)
|
||||
}
|
||||
|
||||
// GearForActivity will retrieve the gear associated with an activity
|
||||
func (c *Client) GearForActivity(profileID int64, activityID int) ([]Gear, error) {
|
||||
if profileID == 0 && c.Profile == nil {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if profileID == 0 && c.Profile != nil {
|
||||
profileID = c.Profile.ProfileID
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/gear-service/gear/filterGear?userProfilePk=%d&activityId=%d",
|
||||
profileID, activityID,
|
||||
)
|
||||
var gear []Gear
|
||||
err := c.getJSON(URL, &gear)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return gear, nil
|
||||
}
|
||||
115
python-garmin-connect/Goal.go
Normal file
115
python-garmin-connect/Goal.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Goal represents a fitness or health goal.
|
||||
type Goal struct {
|
||||
ID int64 `json:"id"`
|
||||
ProfileID int64 `json:"userProfilePK"`
|
||||
GoalCategory int `json:"userGoalCategoryPK"`
|
||||
GoalType GoalType `json:"userGoalTypePK"`
|
||||
Start Date `json:"startDate"`
|
||||
End Date `json:"endDate,omitempty"`
|
||||
Value int `json:"goalValue"`
|
||||
Created Date `json:"createDate"`
|
||||
}
|
||||
|
||||
// GoalType represents different types of goals.
|
||||
type GoalType int
|
||||
|
||||
// String implements Stringer.
|
||||
func (t GoalType) String() string {
|
||||
switch t {
|
||||
case 0:
|
||||
return "steps-per-day"
|
||||
case 4:
|
||||
return "weight"
|
||||
case 7:
|
||||
return "floors-ascended"
|
||||
default:
|
||||
return fmt.Sprintf("unknown:%d", t)
|
||||
}
|
||||
}
|
||||
|
||||
// Goals lists all goals for displayName of type goalType. If displayName is
|
||||
// empty, the currently authenticated user will be used.
|
||||
func (c *Client) Goals(displayName string, goalType int) ([]Goal, error) {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/wellness-goals/%s?userGoalType=%d",
|
||||
displayName,
|
||||
goalType,
|
||||
)
|
||||
|
||||
goals := make([]Goal, 0, 20)
|
||||
|
||||
err := c.getJSON(URL, &goals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return goals, nil
|
||||
}
|
||||
|
||||
// AddGoal will add a new goal. If displayName is empty, the currently
|
||||
// authenticated user will be used.
|
||||
func (c *Client) AddGoal(displayName string, goal Goal) error {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/wellness-goals/%s",
|
||||
displayName,
|
||||
)
|
||||
|
||||
return c.write("POST", URL, goal, 204)
|
||||
}
|
||||
|
||||
// DeleteGoal will delete an existing goal. If displayName is empty, the
|
||||
// currently authenticated user will be used.
|
||||
func (c *Client) DeleteGoal(displayName string, goalID int) error {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/wellness-goals/%d/%s",
|
||||
goalID,
|
||||
displayName,
|
||||
)
|
||||
|
||||
return c.write("DELETE", URL, nil, 204)
|
||||
}
|
||||
|
||||
// UpdateGoal will update an existing goal.
|
||||
func (c *Client) UpdateGoal(displayName string, goal Goal) error {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/wellness-goals/%d/%s",
|
||||
goal.ID,
|
||||
displayName,
|
||||
)
|
||||
|
||||
return c.write("PUT", URL, goal, 204)
|
||||
}
|
||||
153
python-garmin-connect/Group.go
Normal file
153
python-garmin-connect/Group.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Group describes a Garmin Connect group.
|
||||
type Group struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"groupName"`
|
||||
Description string `json:"groupDescription"`
|
||||
OwnerID int `json:"ownerId"`
|
||||
ProfileImageURLLarge string `json:"profileImageUrlLarge"`
|
||||
ProfileImageURLMedium string `json:"profileImageUrlMedium"`
|
||||
ProfileImageURLSmall string `json:"profileImageUrlSmall"`
|
||||
Visibility string `json:"groupVisibility"`
|
||||
Privacy string `json:"groupPrivacy"`
|
||||
Location string `json:"location"`
|
||||
WebsiteURL string `json:"websiteUrl"`
|
||||
FacebookURL string `json:"facebookUrl"`
|
||||
TwitterURL string `json:"twitterUrl"`
|
||||
PrimaryActivities []string `json:"primaryActivities"`
|
||||
OtherPrimaryActivity string `json:"otherPrimaryActivity"`
|
||||
LeaderboardTypes []string `json:"leaderboardTypes"`
|
||||
FeatureTypes []string `json:"featureTypes"`
|
||||
CorporateWellness bool `json:"isCorporateWellness"`
|
||||
ActivityFeedTypes []ActivityType `json:"activityFeedTypes"`
|
||||
}
|
||||
|
||||
/*
|
||||
Unknowns:
|
||||
"membershipStatus": null,
|
||||
"isCorporateWellness": false,
|
||||
"programName": null,
|
||||
"programTextColor": null,
|
||||
"programBackgroundColor": null,
|
||||
"groupMemberCount": null,
|
||||
*/
|
||||
|
||||
// Groups will return the group membership. If displayName is empty, the
|
||||
// currently authenticated user will be used.
|
||||
func (c *Client) Groups(displayName string) ([]Group, error) {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/groups/%s", displayName)
|
||||
|
||||
groups := make([]Group, 0, 30)
|
||||
|
||||
err := c.getJSON(URL, &groups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// SearchGroups can search for groups in Garmin Connect.
|
||||
func (c *Client) SearchGroups(keyword string) ([]Group, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/group-service/keyword"
|
||||
|
||||
payload := url.Values{
|
||||
"start": {"1"},
|
||||
"limit": {"100"},
|
||||
"keyword": {keyword},
|
||||
}
|
||||
|
||||
req, err := c.newRequest("POST", URL, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("content-type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
|
||||
resp, err := c.do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var proxy struct {
|
||||
Groups []Group `json:"groupDTOs"`
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
err = dec.Decode(&proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return proxy.Groups, nil
|
||||
}
|
||||
|
||||
// Group returns details about groupID.
|
||||
func (c *Client) Group(groupID int) (*Group, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/group/%d", groupID)
|
||||
|
||||
group := new(Group)
|
||||
|
||||
err := c.getJSON(URL, group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// JoinGroup joins a group. If profileID is 0, the currently authenticated
|
||||
// user will be used.
|
||||
func (c *Client) JoinGroup(groupID int) error {
|
||||
if c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/group/%d/member/%d",
|
||||
groupID,
|
||||
c.Profile.ProfileID,
|
||||
)
|
||||
|
||||
payload := struct {
|
||||
GroupID int `json:"groupId"`
|
||||
Role *string `json:"groupRole"` // is always null?
|
||||
ProfileID int64 `json:"userProfileId"`
|
||||
}{
|
||||
groupID,
|
||||
nil,
|
||||
c.Profile.ProfileID,
|
||||
}
|
||||
|
||||
return c.write("POST", URL, payload, 200)
|
||||
}
|
||||
|
||||
// LeaveGroup leaves a group.
|
||||
func (c *Client) LeaveGroup(groupID int) error {
|
||||
if c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/group/%d/member/%d",
|
||||
groupID,
|
||||
c.Profile.ProfileID,
|
||||
)
|
||||
|
||||
return c.write("DELETE", URL, nil, 204)
|
||||
}
|
||||
31
python-garmin-connect/GroupAnnouncement.go
Normal file
31
python-garmin-connect/GroupAnnouncement.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// GroupAnnouncement describes a group announcement. Only one announcement can
|
||||
// exist per group.
|
||||
type GroupAnnouncement struct {
|
||||
ID int `json:"announcementId"`
|
||||
GroupID int `json:"groupId"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
ExpireDate Time `json:"expireDate"`
|
||||
AnnouncementDate Time `json:"announcementDate"`
|
||||
}
|
||||
|
||||
// GroupAnnouncement returns the announcement for groupID.
|
||||
func (c *Client) GroupAnnouncement(groupID int) (*GroupAnnouncement, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/group/%d/announcement",
|
||||
groupID,
|
||||
)
|
||||
|
||||
announcement := new(GroupAnnouncement)
|
||||
err := c.getJSON(URL, announcement)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return announcement, nil
|
||||
}
|
||||
60
python-garmin-connect/GroupMember.go
Normal file
60
python-garmin-connect/GroupMember.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GroupMember describes a member of a group.
|
||||
type GroupMember struct {
|
||||
SocialProfile
|
||||
|
||||
Joined time.Time `json:"joinDate"`
|
||||
Role string `json:"groupRole"`
|
||||
}
|
||||
|
||||
// GroupMembers will return the member list of a group.
|
||||
func (c *Client) GroupMembers(groupID int) ([]GroupMember, error) {
|
||||
type proxy struct {
|
||||
ID string `json:"id"`
|
||||
GroupID int `json:"groupId"`
|
||||
UserProfileID int64 `json:"userProfileId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Location string `json:"location"`
|
||||
Joined Date `json:"joinDate"`
|
||||
Role string `json:"groupRole"`
|
||||
Name string `json:"fullName"`
|
||||
ProfileImageURLLarge string `json:"profileImageLarge"`
|
||||
ProfileImageURLMedium string `json:"profileImageMedium"`
|
||||
ProfileImageURLSmall string `json:"profileImageSmall"`
|
||||
Pro bool `json:"userPro"`
|
||||
Level int `json:"userLevel"`
|
||||
}
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/group-service/group/%d/members",
|
||||
groupID,
|
||||
)
|
||||
|
||||
membersProxy := make([]proxy, 0, 100)
|
||||
err := c.getJSON(URL, &membersProxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
members := make([]GroupMember, len(membersProxy))
|
||||
for i, p := range membersProxy {
|
||||
members[i].DisplayName = p.DisplayName
|
||||
members[i].ProfileID = p.UserProfileID
|
||||
members[i].DisplayName = p.DisplayName
|
||||
members[i].Location = p.Location
|
||||
members[i].Fullname = p.Name
|
||||
members[i].ProfileImageURLLarge = p.ProfileImageURLLarge
|
||||
members[i].ProfileImageURLMedium = p.ProfileImageURLMedium
|
||||
members[i].ProfileImageURLSmall = p.ProfileImageURLSmall
|
||||
members[i].UserLevel = p.Level
|
||||
|
||||
members[i].Joined = p.Joined.Time()
|
||||
members[i].Role = p.Role
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
21
python-garmin-connect/LICENSE
Normal file
21
python-garmin-connect/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Anders Brander
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
27
python-garmin-connect/LastUsed.go
Normal file
27
python-garmin-connect/LastUsed.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package connect
|
||||
|
||||
// LastUsed describes the last synchronization.
|
||||
type LastUsed struct {
|
||||
DeviceID int `json:"userDeviceId"`
|
||||
ProfileNumber int `json:"userProfileNumber"`
|
||||
ApplicationNumber int `json:"applicationNumber"`
|
||||
DeviceApplicationKey string `json:"lastUsedDeviceApplicationKey"`
|
||||
DeviceName string `json:"lastUsedDeviceName"`
|
||||
DeviceUploadTime Time `json:"lastUsedDeviceUploadTime"`
|
||||
ImageURL string `json:"imageUrl"`
|
||||
Released bool `json:"released"`
|
||||
}
|
||||
|
||||
// LastUsed will return information about the latest synchronization.
|
||||
func (c *Client) LastUsed(displayName string) (*LastUsed, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/device-service/deviceservice/userlastused/" + displayName
|
||||
|
||||
lastused := new(LastUsed)
|
||||
|
||||
err := c.getJSON(URL, lastused)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return lastused, err
|
||||
}
|
||||
34
python-garmin-connect/LifetimeActivities.go
Normal file
34
python-garmin-connect/LifetimeActivities.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// LifetimeActivities is describing a basic summary of all activities.
|
||||
type LifetimeActivities struct {
|
||||
Activities int `json:"totalActivities"` // The number of activities
|
||||
Distance float64 `json:"totalDistance"` // The total distance in meters
|
||||
Duration float64 `json:"totalDuration"` // The duration of all activities in seconds
|
||||
Calories float64 `json:"totalCalories"` // Energy in C
|
||||
ElevationGain float64 `json:"totalElevationGain"` // Total elevation gain in meters
|
||||
}
|
||||
|
||||
// LifetimeActivities will return some aggregated data about all activities.
|
||||
func (c *Client) LifetimeActivities(displayName string) (*LifetimeActivities, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userstats-service/statistics/" + displayName
|
||||
|
||||
var proxy struct {
|
||||
Activities []LifetimeActivities `json:"userMetrics"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(proxy.Activities) != 1 {
|
||||
return nil, errors.New("unexpected data")
|
||||
}
|
||||
|
||||
return &proxy.Activities[0], err
|
||||
}
|
||||
25
python-garmin-connect/LifetimeTotals.go
Normal file
25
python-garmin-connect/LifetimeTotals.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package connect
|
||||
|
||||
// LifetimeTotals is ligetime statistics for the Connect user.
|
||||
type LifetimeTotals struct {
|
||||
ProfileID int `json:"userProfileId"`
|
||||
ActiveDays int `json:"totalActiveDays"`
|
||||
Calories float64 `json:"totalCalories"`
|
||||
Distance int `json:"totalDistance"`
|
||||
GoalsMetInDays int `json:"totalGoalsMetInDays"`
|
||||
Steps int `json:"totalSteps"`
|
||||
}
|
||||
|
||||
// LifetimeTotals returns some lifetime statistics for displayName.
|
||||
func (c *Client) LifetimeTotals(displayName string) (*LifetimeTotals, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/usersummary-service/stats/connectLifetimeTotals/" + displayName
|
||||
|
||||
totals := new(LifetimeTotals)
|
||||
|
||||
err := c.getJSON(URL, totals)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return totals, err
|
||||
}
|
||||
11
python-garmin-connect/Logger.go
Normal file
11
python-garmin-connect/Logger.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package connect
|
||||
|
||||
// Logger defines the interface understood by the Connect client for logging.
|
||||
type Logger interface {
|
||||
Printf(format string, v ...interface{})
|
||||
}
|
||||
|
||||
type discardLog struct{}
|
||||
|
||||
func (*discardLog) Printf(format string, v ...interface{}) {
|
||||
}
|
||||
39
python-garmin-connect/PersonalInformation.go
Normal file
39
python-garmin-connect/PersonalInformation.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package connect
|
||||
|
||||
// BiometricProfile holds key biometric data.
|
||||
type BiometricProfile struct {
|
||||
UserID int `json:"userId"`
|
||||
Height float64 `json:"height"`
|
||||
Weight float64 `json:"weight"` // grams
|
||||
VO2Max float64 `json:"vo2Max"`
|
||||
VO2MaxCycling float64 `json:"vo2MaxCycling"`
|
||||
}
|
||||
|
||||
// UserInfo is very basic information about a user.
|
||||
type UserInfo struct {
|
||||
Gender string `json:"genderType"`
|
||||
Email string `json:"email"`
|
||||
Locale string `json:"locale"`
|
||||
TimeZone string `json:"timezone"`
|
||||
Age int `json:"age"`
|
||||
}
|
||||
|
||||
// PersonalInformation is user info and a biometric profile for a user.
|
||||
type PersonalInformation struct {
|
||||
UserInfo UserInfo `json:"userInfo"`
|
||||
BiometricProfile BiometricProfile `json:"biometricProfile"`
|
||||
}
|
||||
|
||||
// PersonalInformation will retrieve personal information for displayName.
|
||||
func (c *Client) PersonalInformation(displayName string) (*PersonalInformation, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/userprofile/personal-information/" + displayName
|
||||
|
||||
pi := new(PersonalInformation)
|
||||
|
||||
err := c.getJSON(URL, pi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
22
python-garmin-connect/README.md
Normal file
22
python-garmin-connect/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# garmin-connect
|
||||
|
||||
Golang client for the Garmin Connect API.
|
||||
|
||||
This is nothing but a proof of concept, and the API may change at any time.
|
||||
|
||||
[![GoDoc][1]][2]
|
||||
|
||||
[1]: https://godoc.org/github.com/abrander/garmin-connect?status.svg
|
||||
[2]: https://godoc.org/github.com/abrander/garmin-connect
|
||||
|
||||
# Install
|
||||
|
||||
The `connect` CLI app can be installed using `go install`, and the package using `go get`.
|
||||
|
||||
```
|
||||
go install github.com/abrander/garmin-connect/connect@latest
|
||||
```
|
||||
|
||||
```
|
||||
go get github.com/abrander/garmin-connect@latest
|
||||
```
|
||||
52
python-garmin-connect/SleepState.go
Normal file
52
python-garmin-connect/SleepState.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package connect
|
||||
|
||||
// SleepState is used to describe the state of sleep with a device capable
|
||||
// of measuring sleep health.
|
||||
type SleepState int
|
||||
|
||||
// Known sleep states in Garmin Connect.
|
||||
const (
|
||||
SleepStateUnknown SleepState = -1
|
||||
SleepStateDeep SleepState = 0
|
||||
SleepStateLight SleepState = 1
|
||||
SleepStateREM SleepState = 2
|
||||
SleepStateAwake SleepState = 3
|
||||
)
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler.
|
||||
func (s *SleepState) UnmarshalJSON(value []byte) error {
|
||||
// Garmin abuses floats to transfers enums. We ignore the value, and
|
||||
// simply compares them as strings.
|
||||
switch string(value) {
|
||||
case "0.0":
|
||||
*s = SleepStateDeep
|
||||
case "1.0":
|
||||
*s = SleepStateLight
|
||||
case "2.0":
|
||||
*s = SleepStateREM
|
||||
case "3.0":
|
||||
*s = SleepStateAwake
|
||||
default:
|
||||
*s = SleepStateUnknown
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sleep implements fmt.Stringer.
|
||||
func (s SleepState) String() string {
|
||||
m := map[SleepState]string{
|
||||
SleepStateUnknown: "Unknown",
|
||||
SleepStateDeep: "Deep",
|
||||
SleepStateLight: "Light",
|
||||
SleepStateREM: "REM",
|
||||
SleepStateAwake: "Awake",
|
||||
}
|
||||
|
||||
str, found := m[s]
|
||||
if !found {
|
||||
str = m[SleepStateUnknown]
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
89
python-garmin-connect/SleepSummary.go
Normal file
89
python-garmin-connect/SleepSummary.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "sleepQualityTypePK": null,
|
||||
// "sleepResultTypePK": null,
|
||||
|
||||
// SleepSummary is a summary of sleep for a single night.
|
||||
type SleepSummary struct {
|
||||
ID int64 `json:"id"`
|
||||
UserProfilePK int64 `json:"userProfilePK"`
|
||||
Sleep time.Duration `json:"sleepTimeSeconds"`
|
||||
Nap time.Duration `json:"napTimeSeconds"`
|
||||
Confirmed bool `json:"sleepWindowConfirmed"`
|
||||
Confirmation string `json:"sleepWindowConfirmationType"`
|
||||
StartGMT Time `json:"sleepStartTimestampGMT"`
|
||||
EndGMT Time `json:"sleepEndTimestampGMT"`
|
||||
StartLocal Time `json:"sleepStartTimestampLocal"`
|
||||
EndLocal Time `json:"sleepEndTimestampLocal"`
|
||||
AutoStartGMT Time `json:"autoSleepStartTimestampGMT"`
|
||||
AutoEndGMT Time `json:"autoSleepEndTimestampGMT"`
|
||||
Unmeasurable time.Duration `json:"unmeasurableSleepSeconds"`
|
||||
Deep time.Duration `json:"deepSleepSeconds"`
|
||||
Light time.Duration `json:"lightSleepSeconds"`
|
||||
REM time.Duration `json:"remSleepSeconds"`
|
||||
Awake time.Duration `json:"awakeSleepSeconds"`
|
||||
DeviceRemCapable bool `json:"deviceRemCapable"`
|
||||
REMData bool `json:"remData"`
|
||||
}
|
||||
|
||||
// SleepMovement denotes the amount of movement for a short time period
|
||||
// during sleep.
|
||||
type SleepMovement struct {
|
||||
Start Time `json:"startGMT"`
|
||||
End Time `json:"endGMT"`
|
||||
Level float64 `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepLevel represents the sleep level for a longer period of time.
|
||||
type SleepLevel struct {
|
||||
Start Time `json:"startGMT"`
|
||||
End Time `json:"endGMT"`
|
||||
State SleepState `json:"activityLevel"`
|
||||
}
|
||||
|
||||
// SleepData will retrieve sleep data for date for a given displayName. If
|
||||
// displayName is empty, the currently authenticated user will be used.
|
||||
func (c *Client) SleepData(displayName string, date time.Time) (*SleepSummary, []SleepMovement, []SleepLevel, error) {
|
||||
if displayName == "" && c.Profile == nil {
|
||||
return nil, nil, nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
if displayName == "" && c.Profile != nil {
|
||||
displayName = c.Profile.DisplayName
|
||||
}
|
||||
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
displayName,
|
||||
formatDate(date),
|
||||
)
|
||||
|
||||
var proxy struct {
|
||||
SleepSummary SleepSummary `json:"dailySleepDTO"`
|
||||
REMData bool `json:"remSleepData"`
|
||||
Movement []SleepMovement `json:"sleepMovement"`
|
||||
Levels []SleepLevel `json:"sleepLevels"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
// All timings from Garmin are in seconds.
|
||||
proxy.SleepSummary.Sleep *= time.Second
|
||||
proxy.SleepSummary.Nap *= time.Second
|
||||
proxy.SleepSummary.Unmeasurable *= time.Second
|
||||
proxy.SleepSummary.Deep *= time.Second
|
||||
proxy.SleepSummary.Light *= time.Second
|
||||
proxy.SleepSummary.REM *= time.Second
|
||||
proxy.SleepSummary.Awake *= time.Second
|
||||
|
||||
proxy.SleepSummary.REMData = proxy.REMData
|
||||
|
||||
return &proxy.SleepSummary, proxy.Movement, proxy.Levels, nil
|
||||
}
|
||||
79
python-garmin-connect/SocialProfile.go
Normal file
79
python-garmin-connect/SocialProfile.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package connect
|
||||
|
||||
// SocialProfile represents a Garmin Connect user.
|
||||
type SocialProfile struct {
|
||||
ID int64 `json:"id"`
|
||||
ProfileID int64 `json:"profileId"`
|
||||
ConnectionRequestID int `json:"connectionRequestId"`
|
||||
GarminGUID string `json:"garminGUID"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Fullname string `json:"fullName"`
|
||||
Username string `json:"userName"`
|
||||
ProfileImageURLLarge string `json:"profileImageUrlLarge"`
|
||||
ProfileImageURLMedium string `json:"profileImageUrlMedium"`
|
||||
ProfileImageURLSmall string `json:"profileImageUrlSmall"`
|
||||
Location string `json:"location"`
|
||||
FavoriteActivityTypes []string `json:"favoriteActivityTypes"`
|
||||
UserRoles []string `json:"userRoles"`
|
||||
UserProfileFullName string `json:"userProfileFullName"`
|
||||
UserLevel int `json:"userLevel"`
|
||||
UserPoint int `json:"userPoint"`
|
||||
}
|
||||
|
||||
// SocialProfile retrieves a profile for a Garmin Connect user. If displayName
|
||||
// is empty, the profile for the currently authenticated user will be returned.
|
||||
func (c *Client) SocialProfile(displayName string) (*SocialProfile, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/socialProfile/" + displayName
|
||||
|
||||
profile := new(SocialProfile)
|
||||
|
||||
err := c.getJSON(URL, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profile, err
|
||||
}
|
||||
|
||||
// PublicSocialProfile retrieves the public profile for displayName.
|
||||
func (c *Client) PublicSocialProfile(displayName string) (*SocialProfile, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userprofile-service/socialProfile/public/" + displayName
|
||||
|
||||
profile := new(SocialProfile)
|
||||
|
||||
err := c.getJSON(URL, profile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return profile, err
|
||||
}
|
||||
|
||||
// BlockedUsers returns the list of blocked users for the currently
|
||||
// authenticated user.
|
||||
func (c *Client) BlockedUsers() ([]SocialProfile, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userblock-service/blockuser"
|
||||
|
||||
var results []SocialProfile
|
||||
|
||||
err := c.getJSON(URL, &results)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// BlockUser will block a user.
|
||||
func (c *Client) BlockUser(displayName string) error {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userblock-service/blockuser/" + displayName
|
||||
|
||||
return c.write("POST", URL, nil, 200)
|
||||
}
|
||||
|
||||
// UnblockUser removed displayName from the block list.
|
||||
func (c *Client) UnblockUser(displayName string) error {
|
||||
URL := "https://connect.garmin.com/modern/proxy/userblock-service/blockuser/" + displayName
|
||||
|
||||
return c.write("DELETE", URL, nil, 204)
|
||||
}
|
||||
55
python-garmin-connect/Time.go
Normal file
55
python-garmin-connect/Time.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Time is a type masking a time.Time capable of parsing the JSON from
|
||||
// Garmin Connect.
|
||||
type Time struct{ time.Time }
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler. It can parse timestamps
|
||||
// returned from connect.garmin.com.
|
||||
func (t *Time) UnmarshalJSON(value []byte) error {
|
||||
// Sometimes timestamps are transferred as milliseconds since epoch :-/
|
||||
i, err := strconv.ParseInt(string(value), 10, 64)
|
||||
if err == nil && i > 1000000000000 {
|
||||
t.Time = time.Unix(i/1000, 0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FIXME: Somehow we should deal with timezones :-/
|
||||
layouts := []string{
|
||||
"2006-01-02T15:04:05Z", // Support Gos own format.
|
||||
"2006-01-02T15:04:05.0",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
var blip string
|
||||
err = json.Unmarshal(value, &blip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var proxy time.Time
|
||||
for _, l := range layouts {
|
||||
proxy, err = time.Parse(l, blip)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
t.Time = proxy
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler.
|
||||
func (t *Time) MarshalJSON() ([]byte, error) {
|
||||
b, err := t.Time.MarshalJSON()
|
||||
|
||||
return b, err
|
||||
}
|
||||
21
python-garmin-connect/Time_test.go
Normal file
21
python-garmin-connect/Time_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTimeUnmarshalJSON(t *testing.T) {
|
||||
var t0 Time
|
||||
|
||||
input := []byte(`"2019-01-12T11:45:23.0"`)
|
||||
|
||||
err := json.Unmarshal(input, &t0)
|
||||
if err != nil {
|
||||
t.Fatalf("Error parsing %s: %s", string(input), err.Error())
|
||||
}
|
||||
|
||||
if t0.String() != "2019-01-12 11:45:23 +0000 UTC" {
|
||||
t.Errorf("Failed to parse `%s` correct, got %s", string(input), t0.String())
|
||||
}
|
||||
}
|
||||
20
python-garmin-connect/Timezone.go
Normal file
20
python-garmin-connect/Timezone.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timezone represents a timezone in Garmin Connect.
|
||||
type Timezone struct {
|
||||
ID int `json:"unitId"`
|
||||
Key string `json:"unitKey"`
|
||||
GMTOffset float64 `json:"gmtOffset"`
|
||||
DSTOffset float64 `json:"dstOffset"`
|
||||
Group int `json:"groupNumber"`
|
||||
TimeZone string `json:"timeZone"`
|
||||
}
|
||||
|
||||
// Location will (try to) return a location for use with time.Time functions.
|
||||
func (t *Timezone) Location() (*time.Location, error) {
|
||||
return time.LoadLocation(t.Key)
|
||||
}
|
||||
44
python-garmin-connect/Timezones.go
Normal file
44
python-garmin-connect/Timezones.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package connect
|
||||
|
||||
// Timezones is the list of known time zones in Garmin Connect.
|
||||
type Timezones []Timezone
|
||||
|
||||
// Timezones will retrieve the list of known timezones in Garmin Connect.
|
||||
func (c *Client) Timezones() (Timezones, error) {
|
||||
URL := "https://connect.garmin.com/modern/proxy/system-service/timezoneUnits"
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
timezones := make(Timezones, 0, 100)
|
||||
|
||||
err := c.getJSON(URL, &timezones)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return timezones, nil
|
||||
}
|
||||
|
||||
// FindID will search for the timezone with id.
|
||||
func (ts Timezones) FindID(id int) (Timezone, bool) {
|
||||
for _, t := range ts {
|
||||
if t.ID == id {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
|
||||
return Timezone{}, false
|
||||
}
|
||||
|
||||
// FindKey will search for the timezone with key key.
|
||||
func (ts Timezones) FindKey(key string) (Timezone, bool) {
|
||||
for _, t := range ts {
|
||||
if t.Key == key {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
|
||||
return Timezone{}, false
|
||||
}
|
||||
167
python-garmin-connect/Weight.go
Normal file
167
python-garmin-connect/Weight.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Weightin is a single weight event.
|
||||
type Weightin struct {
|
||||
Date Date `json:"date"`
|
||||
Version int `json:"version"`
|
||||
Weight float64 `json:"weight"` // gram
|
||||
BMI float64 `json:"bmi"` // weight / height²
|
||||
BodyFatPercentage float64 `json:"bodyFat"` // percent
|
||||
BodyWater float64 `json:"bodyWater"` // kilogram
|
||||
BoneMass int `json:"boneMass"` // gram
|
||||
MuscleMass int `json:"muscleMass"` // gram
|
||||
SourceType string `json:"sourceType"`
|
||||
}
|
||||
|
||||
// WeightAverage is aggregated weight data for a specific period.
|
||||
type WeightAverage struct {
|
||||
Weightin
|
||||
From int `json:"from"`
|
||||
Until int `json:"until"`
|
||||
}
|
||||
|
||||
// LatestWeight will retrieve the latest weight by date.
|
||||
func (c *Client) LatestWeight(date time.Time) (*Weightin, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/weight-service/weight/latest?date=%04d-%02d-%02d",
|
||||
date.Year(),
|
||||
date.Month(),
|
||||
date.Day())
|
||||
|
||||
wi := new(Weightin)
|
||||
|
||||
err := c.getJSON(URL, wi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return wi, nil
|
||||
}
|
||||
|
||||
// Weightins will retrieve all weight ins between startDate and endDate. A
|
||||
// summary is provided as well. This summary is calculated by Garmin Connect.
|
||||
func (c *Client) Weightins(startDate time.Time, endDate time.Time) (*WeightAverage, []Weightin, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/weight-service/weight/dateRange?startDate=%s&endDate=%s",
|
||||
formatDate(startDate),
|
||||
formatDate(endDate))
|
||||
|
||||
// An alternative endpoint for weight info this can be found here:
|
||||
// https://connect.garmin.com/modern/proxy/userprofile-service/userprofile/personal-information/weightWithOutbound?from=1556359100000&until=1556611800000
|
||||
|
||||
if !c.authenticated() {
|
||||
return nil, nil, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy struct {
|
||||
DateWeightList []Weightin `json:"dateWeightList"`
|
||||
TotalAverage *WeightAverage `json:"totalAverage"`
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return proxy.TotalAverage, proxy.DateWeightList, nil
|
||||
}
|
||||
|
||||
// DeleteWeightin will delete all biometric data for date.
|
||||
func (c *Client) DeleteWeightin(date time.Time) error {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/biometric-service/biometric/%s", formatDate(date))
|
||||
|
||||
if !c.authenticated() {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
return c.write("DELETE", URL, nil, 204)
|
||||
}
|
||||
|
||||
// AddUserWeight will add a manual weight in. weight is in grams to match
|
||||
// Weightin.
|
||||
func (c *Client) AddUserWeight(date time.Time, weight float64) error {
|
||||
URL := "https://connect.garmin.com/modern/proxy/weight-service/user-weight"
|
||||
payload := struct {
|
||||
Date string `json:"date"`
|
||||
UnitKey string `json:"unitKey"`
|
||||
Value float64 `json:"value"`
|
||||
}{
|
||||
Date: formatDate(date),
|
||||
UnitKey: "kg",
|
||||
Value: weight / 1000.0,
|
||||
}
|
||||
|
||||
return c.write("POST", URL, payload, 204)
|
||||
}
|
||||
|
||||
// WeightByDate retrieves the weight of date if available. If no weight data
|
||||
// for date exists, it will return ErrNotFound.
|
||||
func (c *Client) WeightByDate(date time.Time) (Time, float64, error) {
|
||||
URL := fmt.Sprintf("https://connect.garmin.com/modern/proxy/biometric-service/biometric/weightByDate?date=%s",
|
||||
formatDate(date))
|
||||
|
||||
if !c.authenticated() {
|
||||
return Time{}, 0.0, ErrNotAuthenticated
|
||||
}
|
||||
|
||||
var proxy []struct {
|
||||
TimeStamp Time `json:"weightDate"`
|
||||
Weight float64 `json:"weight"` // gram
|
||||
}
|
||||
|
||||
err := c.getJSON(URL, &proxy)
|
||||
if err != nil {
|
||||
return Time{}, 0.0, err
|
||||
}
|
||||
|
||||
if len(proxy) < 1 {
|
||||
return Time{}, 0.0, ErrNotFound
|
||||
}
|
||||
|
||||
return proxy[0].TimeStamp, proxy[0].Weight, nil
|
||||
}
|
||||
|
||||
// WeightGoal will list the users weight goal if any. If displayName is empty,
|
||||
// the currently authenticated user will be used.
|
||||
func (c *Client) WeightGoal(displayName string) (*Goal, error) {
|
||||
goals, err := c.Goals(displayName, 4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(goals) < 1 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
return &goals[0], nil
|
||||
}
|
||||
|
||||
// SetWeightGoal will set a new weight goal.
|
||||
func (c *Client) SetWeightGoal(goal int) error {
|
||||
if !c.authenticated() || c.Profile == nil {
|
||||
return ErrNotAuthenticated
|
||||
}
|
||||
|
||||
g := Goal{
|
||||
Created: Today(),
|
||||
Start: Today(),
|
||||
GoalType: 4,
|
||||
ProfileID: c.Profile.ProfileID,
|
||||
Value: goal,
|
||||
}
|
||||
|
||||
goals, err := c.Goals("", 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(goals) >= 1 {
|
||||
g.ID = goals[0].ID
|
||||
return c.UpdateGoal("", g)
|
||||
}
|
||||
|
||||
return c.AddGoal(c.Profile.DisplayName, g)
|
||||
}
|
||||
1
python-garmin-connect/connect/.gitignore
vendored
Normal file
1
python-garmin-connect/connect/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/connect
|
||||
1
python-garmin-connect/connect/README.md
Normal file
1
python-garmin-connect/connect/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This is a simple CLI client for Garmin Connect.
|
||||
81
python-garmin-connect/connect/Table.go
Normal file
81
python-garmin-connect/connect/Table.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Table struct {
|
||||
columnsMax []int
|
||||
header []string
|
||||
rows [][]string
|
||||
}
|
||||
|
||||
func NewTable() *Table {
|
||||
return &Table{}
|
||||
}
|
||||
|
||||
func (t *Table) AddHeader(titles ...string) {
|
||||
t.header = titles
|
||||
t.columnsMax = make([]int, len(t.header))
|
||||
for i, title := range t.header {
|
||||
t.columnsMax[i] = utf8.RuneCountInString(title)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) AddRow(columns ...interface{}) {
|
||||
cols := sliceStringer(columns)
|
||||
|
||||
if len(columns) != len(t.header) {
|
||||
panic("worng number of columns")
|
||||
}
|
||||
|
||||
t.rows = append(t.rows, cols)
|
||||
|
||||
for i, col := range cols {
|
||||
l := utf8.RuneCountInString(col)
|
||||
|
||||
if t.columnsMax[i] < l {
|
||||
t.columnsMax[i] = l
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rightPad(in string, length int) string {
|
||||
result := in
|
||||
inLen := utf8.RuneCountInString(in)
|
||||
|
||||
for i := 0; i < length-inLen; i++ {
|
||||
result += " "
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (t *Table) outputLine(w io.Writer, columns []string) {
|
||||
line := ""
|
||||
|
||||
for i, column := range columns {
|
||||
line += rightPad(column, t.columnsMax[i]) + " "
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\n", line)
|
||||
}
|
||||
|
||||
func (t *Table) outputHeader(w io.Writer, columns []string) {
|
||||
line := ""
|
||||
|
||||
for i, column := range columns {
|
||||
line += "\033[1m" + rightPad(column, t.columnsMax[i]) + "\033[0m "
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s\n", line)
|
||||
}
|
||||
|
||||
func (t *Table) Output(writer io.Writer) {
|
||||
t.outputHeader(writer, t.header)
|
||||
for _, row := range t.rows {
|
||||
t.outputLine(writer, row)
|
||||
}
|
||||
}
|
||||
63
python-garmin-connect/connect/Tabular.go
Normal file
63
python-garmin-connect/connect/Tabular.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type Tabular struct {
|
||||
maxLength int
|
||||
titles []string
|
||||
values []Value
|
||||
}
|
||||
|
||||
type Value struct {
|
||||
Unit string
|
||||
Value interface{}
|
||||
}
|
||||
|
||||
func (v Value) String() string {
|
||||
str := stringer(v.Value)
|
||||
|
||||
return "\033[1m" + str + "\033[0m " + v.Unit
|
||||
}
|
||||
|
||||
func NewTabular() *Tabular {
|
||||
return &Tabular{}
|
||||
}
|
||||
|
||||
func (t *Tabular) AddValue(title string, value interface{}) {
|
||||
t.AddValueUnit(title, value, "")
|
||||
}
|
||||
|
||||
func (t *Tabular) AddValueUnit(title string, value interface{}, unit string) {
|
||||
v := Value{
|
||||
Unit: unit,
|
||||
Value: value,
|
||||
}
|
||||
|
||||
t.titles = append(t.titles, title)
|
||||
t.values = append(t.values, v)
|
||||
|
||||
if len(title) > t.maxLength {
|
||||
t.maxLength = len(title)
|
||||
}
|
||||
}
|
||||
|
||||
func leftPad(in string, length int) string {
|
||||
result := ""
|
||||
inLen := utf8.RuneCountInString(in)
|
||||
|
||||
for i := 0; i < length-inLen; i++ {
|
||||
result += " "
|
||||
}
|
||||
|
||||
return result + in
|
||||
}
|
||||
|
||||
func (t *Tabular) Output(writer io.Writer) {
|
||||
for i, value := range t.values {
|
||||
fmt.Fprintf(writer, "%s %s\n", leftPad(t.titles[i], t.maxLength), value.String())
|
||||
}
|
||||
}
|
||||
217
python-garmin-connect/connect/activities.go
Normal file
217
python-garmin-connect/connect/activities.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
)
|
||||
|
||||
var (
|
||||
exportFormat string
|
||||
offset int
|
||||
count int
|
||||
)
|
||||
|
||||
func init() {
|
||||
activitiesCmd := &cobra.Command{
|
||||
Use: "activities",
|
||||
}
|
||||
rootCmd.AddCommand(activitiesCmd)
|
||||
|
||||
activitiesListCmd := &cobra.Command{
|
||||
Use: "list [display name]",
|
||||
Short: "List Activities",
|
||||
Run: activitiesList,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
activitiesListCmd.Flags().IntVarP(&offset, "offset", "o", 0, "Paginating index where the list starts from")
|
||||
activitiesListCmd.Flags().IntVarP(&count, "count", "c", 100, "Count of elements to return")
|
||||
activitiesCmd.AddCommand(activitiesListCmd)
|
||||
|
||||
activitiesViewCmd := &cobra.Command{
|
||||
Use: "view <activity id>",
|
||||
Short: "View details for an activity",
|
||||
Run: activitiesView,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesCmd.AddCommand(activitiesViewCmd)
|
||||
|
||||
activitiesViewWeatherCmd := &cobra.Command{
|
||||
Use: "weather <activity id>",
|
||||
Short: "View weather for an activity",
|
||||
Run: activitiesViewWeather,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesViewCmd.AddCommand(activitiesViewWeatherCmd)
|
||||
|
||||
activitiesViewHRZonesCmd := &cobra.Command{
|
||||
Use: "hrzones <activity id>",
|
||||
Short: "View hr zones for an activity",
|
||||
Run: activitiesViewHRZones,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesViewCmd.AddCommand(activitiesViewHRZonesCmd)
|
||||
|
||||
activitiesExportCmd := &cobra.Command{
|
||||
Use: "export <activity id>",
|
||||
Short: "Export an activity to a file",
|
||||
Run: activitiesExport,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesExportCmd.Flags().StringVarP(&exportFormat, "format", "f", "fit", "Format of export (fit, tcx, gpx, kml, csv)")
|
||||
activitiesCmd.AddCommand(activitiesExportCmd)
|
||||
|
||||
activitiesImportCmd := &cobra.Command{
|
||||
Use: "import <path>",
|
||||
Short: "Import an activity from a file",
|
||||
Run: activitiesImport,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesCmd.AddCommand(activitiesImportCmd)
|
||||
|
||||
activitiesDeleteCmd := &cobra.Command{
|
||||
Use: "delete <activity id>",
|
||||
Short: "Delete an activity",
|
||||
Run: activitiesDelete,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
activitiesCmd.AddCommand(activitiesDeleteCmd)
|
||||
|
||||
activitiesRenameCmd := &cobra.Command{
|
||||
Use: "rename <activity id> <new name>",
|
||||
Short: "Rename an activity",
|
||||
Run: activitiesRename,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
activitiesCmd.AddCommand(activitiesRenameCmd)
|
||||
}
|
||||
|
||||
func activitiesList(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
|
||||
if len(args) == 1 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
activities, err := client.Activities(displayName, offset, count)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Date", "Name", "Type", "Distance", "Time", "Avg/Max HR", "Calories")
|
||||
for _, a := range activities {
|
||||
t.AddRow(
|
||||
a.ID,
|
||||
a.StartLocal.Time,
|
||||
a.ActivityName,
|
||||
a.ActivityType.TypeKey,
|
||||
a.Distance,
|
||||
a.StartLocal,
|
||||
fmt.Sprintf("%.0f/%.0f", a.AverageHeartRate, a.MaxHeartRate),
|
||||
a.Calories,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func activitiesView(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
activity, err := client.Activity(activityID)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", activity.ID)
|
||||
t.AddValue("Name", activity.ActivityName)
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func activitiesViewWeather(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
weather, err := client.ActivityWeather(activityID)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValueUnit("Temperature", weather.Temperature, "°F")
|
||||
t.AddValueUnit("Apparent Temperature", weather.ApparentTemperature, "°F")
|
||||
t.AddValueUnit("Dew Point", weather.DewPoint, "°F")
|
||||
t.AddValueUnit("Relative Humidity", weather.RelativeHumidity, "%")
|
||||
t.AddValueUnit("Wind Direction", weather.WindDirection, weather.WindDirectionCompassPoint)
|
||||
t.AddValueUnit("Wind Speed", weather.WindSpeed, "mph")
|
||||
t.AddValue("Latitude", weather.Latitude)
|
||||
t.AddValue("Longitude", weather.Longitude)
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func activitiesViewHRZones(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
zones, err := client.ActivityHrZones(activityID)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
//for (zone in zones)
|
||||
for i := 0; i < len(zones)-1; i++ {
|
||||
t.AddValue(fmt.Sprintf("Zone %d (%3d-%3dbpm)", zones[i].ZoneNumber, zones[i].ZoneLowBoundary, zones[i+1].ZoneLowBoundary),
|
||||
zones[i].TimeInZone)
|
||||
}
|
||||
t.AddValue(fmt.Sprintf("Zone %d ( > %dbpm )", zones[len(zones)-1].ZoneNumber, zones[len(zones)-1].ZoneLowBoundary),
|
||||
zones[len(zones)-1].TimeInZone)
|
||||
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func activitiesExport(_ *cobra.Command, args []string) {
|
||||
format, err := connect.FormatFromExtension(exportFormat)
|
||||
bail(err)
|
||||
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
name := fmt.Sprintf("%d.%s", activityID, format.Extension())
|
||||
f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
||||
bail(err)
|
||||
|
||||
err = client.ExportActivity(activityID, f, format)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func activitiesImport(_ *cobra.Command, args []string) {
|
||||
filename := args[0]
|
||||
|
||||
f, err := os.Open(filename)
|
||||
bail(err)
|
||||
|
||||
format, err := connect.FormatFromFilename(filename)
|
||||
bail(err)
|
||||
|
||||
id, err := client.ImportActivity(f, format)
|
||||
bail(err)
|
||||
|
||||
fmt.Printf("Activity ID %d imported\n", id)
|
||||
}
|
||||
|
||||
func activitiesDelete(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.DeleteActivity(activityID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func activitiesRename(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
newName := args[1]
|
||||
|
||||
err = client.RenameActivity(activityID, newName)
|
||||
bail(err)
|
||||
}
|
||||
222
python-garmin-connect/connect/badges.go
Normal file
222
python-garmin-connect/connect/badges.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
)
|
||||
|
||||
const gotIt = "✓"
|
||||
|
||||
func init() {
|
||||
badgesCmd := &cobra.Command{
|
||||
Use: "badges",
|
||||
}
|
||||
rootCmd.AddCommand(badgesCmd)
|
||||
|
||||
badgesLeaderboardCmd := &cobra.Command{
|
||||
Use: "leaderboard",
|
||||
Short: "Show the current points leaderbaord among the authenticated users connections",
|
||||
Run: badgesLeaderboard,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
badgesCmd.AddCommand(badgesLeaderboardCmd)
|
||||
|
||||
badgesEarnedCmd := &cobra.Command{
|
||||
Use: "earned [display name]",
|
||||
Short: "Show the earned badges",
|
||||
Run: badgesEarned,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
badgesCmd.AddCommand(badgesEarnedCmd)
|
||||
|
||||
badgesAvailableCmd := &cobra.Command{
|
||||
Use: "available",
|
||||
Short: "Show badges not yet earned",
|
||||
Run: badgesAvailable,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
badgesCmd.AddCommand(badgesAvailableCmd)
|
||||
|
||||
badgesViewCmd := &cobra.Command{
|
||||
Use: "view <badge id>",
|
||||
Short: "Show details about a badge",
|
||||
Run: badgesView,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
badgesCmd.AddCommand(badgesViewCmd)
|
||||
|
||||
badgesCompareCmd := &cobra.Command{
|
||||
Use: "compare <display name>",
|
||||
Short: "Compare the authenticated users badges with the badges of another user",
|
||||
Run: badgesCompare,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
badgesCmd.AddCommand(badgesCompareCmd)
|
||||
}
|
||||
|
||||
func badgesLeaderboard(_ *cobra.Command, _ []string) {
|
||||
leaderboard, err := client.BadgeLeaderBoard()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Display Name", "Name", "Level", "Points")
|
||||
for _, status := range leaderboard {
|
||||
t.AddRow(status.DisplayName, status.Fullname, status.Level, status.Point)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func badgesEarned(_ *cobra.Command, args []string) {
|
||||
var badges []connect.Badge
|
||||
|
||||
if len(args) == 1 {
|
||||
displayName := args[0]
|
||||
// If we have a displayid to show, we abuse the compare call to read
|
||||
// badges earned by a connection.
|
||||
_, status, err := client.BadgeCompare(displayName)
|
||||
bail(err)
|
||||
|
||||
badges = status.Badges
|
||||
} else {
|
||||
var err error
|
||||
badges, err = client.BadgesEarned()
|
||||
bail(err)
|
||||
}
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Badge", "Points", "Date")
|
||||
for _, badge := range badges {
|
||||
p := fmt.Sprintf("%d", badge.Points)
|
||||
if badge.EarnedNumber > 1 {
|
||||
p = fmt.Sprintf("%d x%d", badge.Points, badge.EarnedNumber)
|
||||
}
|
||||
t.AddRow(badge.ID, badge.Name, p, badge.EarnedDate.String())
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func badgesAvailable(_ *cobra.Command, _ []string) {
|
||||
badges, err := client.BadgesAvailable()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Key", "Name", "Points")
|
||||
for _, badge := range badges {
|
||||
t.AddRow(badge.ID, badge.Key, badge.Name, badge.Points)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func badgesView(_ *cobra.Command, args []string) {
|
||||
badgeID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
badge, err := client.BadgeDetail(badgeID)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", badge.ID)
|
||||
t.AddValue("Key", badge.Key)
|
||||
t.AddValue("Name", badge.Name)
|
||||
t.AddValue("Points", badge.Points)
|
||||
t.AddValue("Earned", formatDate(badge.EarnedDate.Time))
|
||||
t.AddValueUnit("Earned", badge.EarnedNumber, "time(s)")
|
||||
t.AddValue("Available from", formatDate(badge.Start.Time))
|
||||
t.AddValue("Available to", formatDate(badge.End.Time))
|
||||
t.Output(os.Stdout)
|
||||
|
||||
if len(badge.Connections) > 0 {
|
||||
fmt.Printf("\n Connections with badge:\n")
|
||||
t := NewTable()
|
||||
t.AddHeader("Display Name", "Name", "Earned")
|
||||
for _, b := range badge.Connections {
|
||||
t.AddRow(b.DisplayName, b.FullName, b.EarnedDate.Time)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
if len(badge.RelatedBadges) > 0 {
|
||||
fmt.Printf("\n Relates badges:\n")
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Key", "Name", "Points", "Earned")
|
||||
for _, b := range badge.RelatedBadges {
|
||||
earned := ""
|
||||
if b.EarnedByMe {
|
||||
earned = gotIt
|
||||
}
|
||||
t.AddRow(b.ID, b.Key, b.Name, b.Points, earned)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func badgesCompare(_ *cobra.Command, args []string) {
|
||||
displayName := args[0]
|
||||
a, b, err := client.BadgeCompare(displayName)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Badge", a.Fullname, b.Fullname, "Points")
|
||||
|
||||
type status struct {
|
||||
name string
|
||||
points int
|
||||
me bool
|
||||
meEarned int
|
||||
other bool
|
||||
otherEarned int
|
||||
}
|
||||
|
||||
m := map[string]*status{}
|
||||
|
||||
for _, badge := range a.Badges {
|
||||
s, found := m[badge.Key]
|
||||
if !found {
|
||||
s = &status{}
|
||||
m[badge.Key] = s
|
||||
}
|
||||
s.me = true
|
||||
s.meEarned = badge.EarnedNumber
|
||||
s.name = badge.Name
|
||||
s.points = badge.Points
|
||||
}
|
||||
|
||||
for _, badge := range b.Badges {
|
||||
s, found := m[badge.Key]
|
||||
if !found {
|
||||
s = &status{}
|
||||
m[badge.Key] = s
|
||||
}
|
||||
s.other = true
|
||||
s.otherEarned = badge.EarnedNumber
|
||||
s.name = badge.Name
|
||||
s.points = badge.Points
|
||||
}
|
||||
|
||||
for _, e := range m {
|
||||
var me string
|
||||
var other string
|
||||
if e.me {
|
||||
me = gotIt
|
||||
if e.meEarned > 1 {
|
||||
me += fmt.Sprintf(" %dx", e.meEarned)
|
||||
}
|
||||
}
|
||||
|
||||
if e.other {
|
||||
other = gotIt
|
||||
if e.otherEarned > 1 {
|
||||
other += fmt.Sprintf(" %dx", e.otherEarned)
|
||||
}
|
||||
}
|
||||
t.AddRow(e.name, me, other, e.points)
|
||||
}
|
||||
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
114
python-garmin-connect/connect/calendar.go
Normal file
114
python-garmin-connect/connect/calendar.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
calendarCmd := &cobra.Command{
|
||||
Use: "calendar",
|
||||
}
|
||||
rootCmd.AddCommand(calendarCmd)
|
||||
|
||||
calendarYearCmd := &cobra.Command{
|
||||
Use: "year <year>",
|
||||
Short: "List active days in the year",
|
||||
Run: calendarYear,
|
||||
Args: cobra.RangeArgs(1, 1),
|
||||
}
|
||||
calendarCmd.AddCommand(calendarYearCmd)
|
||||
|
||||
calendarMonthCmd := &cobra.Command{
|
||||
Use: "month <year> <month>",
|
||||
Short: "List active days in the month",
|
||||
Run: calendarMonth,
|
||||
Args: cobra.RangeArgs(2, 2),
|
||||
}
|
||||
calendarCmd.AddCommand(calendarMonthCmd)
|
||||
|
||||
calendarWeekCmd := &cobra.Command{
|
||||
Use: "week <year> <month> <day>",
|
||||
Short: "List active days in the week",
|
||||
Run: calendarWeek,
|
||||
Args: cobra.RangeArgs(3, 3),
|
||||
}
|
||||
calendarCmd.AddCommand(calendarWeekCmd)
|
||||
|
||||
}
|
||||
|
||||
func calendarYear(_ *cobra.Command, args []string) {
|
||||
year, err := strconv.ParseInt(args[0], 10, 32)
|
||||
bail(err)
|
||||
|
||||
calendar, err := client.CalendarYear(int(year))
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ActivityType ID", "Number of Activities", "Total Distance", "Total Duration", "Total Calories")
|
||||
for _, summary := range calendar.YearSummaries {
|
||||
t.AddRow(
|
||||
summary.ActivityTypeID,
|
||||
summary.NumberOfActivities,
|
||||
summary.TotalDistance,
|
||||
summary.TotalDuration,
|
||||
summary.TotalCalories,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func calendarMonth(_ *cobra.Command, args []string) {
|
||||
year, err := strconv.ParseInt(args[0], 10, 32)
|
||||
bail(err)
|
||||
|
||||
month, err := strconv.ParseInt(args[1], 10, 32)
|
||||
bail(err)
|
||||
|
||||
calendar, err := client.CalendarMonth(int(year), int(month))
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Date", "Name", "Distance", "Time", "Calories")
|
||||
for _, item := range calendar.CalendarItems {
|
||||
t.AddRow(
|
||||
item.ID,
|
||||
item.Date,
|
||||
item.Title,
|
||||
item.Distance,
|
||||
item.ElapsedDuration,
|
||||
item.Calories,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func calendarWeek(_ *cobra.Command, args []string) {
|
||||
year, err := strconv.ParseInt(args[0], 10, 32)
|
||||
bail(err)
|
||||
|
||||
month, err := strconv.ParseInt(args[1], 10, 32)
|
||||
bail(err)
|
||||
|
||||
week, err := strconv.ParseInt(args[2], 10, 32)
|
||||
bail(err)
|
||||
|
||||
calendar, err := client.CalendarWeek(int(year), int(month), int(week))
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Date", "Name", "Distance", "Time", "Calories")
|
||||
for _, item := range calendar.CalendarItems {
|
||||
t.AddRow(
|
||||
item.ID,
|
||||
item.Date,
|
||||
item.Title,
|
||||
item.Distance,
|
||||
item.ElapsedDuration,
|
||||
item.Calories,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
169
python-garmin-connect/connect/challenges.go
Normal file
169
python-garmin-connect/connect/challenges.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challengesCmd := &cobra.Command{
|
||||
Use: "challenges",
|
||||
}
|
||||
rootCmd.AddCommand(challengesCmd)
|
||||
|
||||
challengesListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List ad-hoc challenges",
|
||||
Run: challengesList,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
challengesCmd.AddCommand(challengesListCmd)
|
||||
|
||||
challengesListInvitesCmd := &cobra.Command{
|
||||
Use: "invites",
|
||||
Short: "List ad-hoc challenge invites",
|
||||
Run: challengesListInvites,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
challengesListCmd.AddCommand(challengesListInvitesCmd)
|
||||
|
||||
challengesAcceptCmd := &cobra.Command{
|
||||
Use: "accept <invation ID>",
|
||||
Short: "Accept an ad-hoc challenge",
|
||||
Run: challengesAccept,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
challengesCmd.AddCommand(challengesAcceptCmd)
|
||||
|
||||
challengesDeclineCmd := &cobra.Command{
|
||||
Use: "decline <invation ID>",
|
||||
Short: "Decline an ad-hoc challenge",
|
||||
Run: challengesDecline,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
challengesCmd.AddCommand(challengesDeclineCmd)
|
||||
|
||||
challengesListPreviousCmd := &cobra.Command{
|
||||
Use: "previous",
|
||||
Short: "Show completed ad-hoc challenges",
|
||||
Run: challengesListPrevious,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
challengesListCmd.AddCommand(challengesListPreviousCmd)
|
||||
|
||||
challengesViewCmd := &cobra.Command{
|
||||
Use: "view <id>",
|
||||
Short: "View challenge details",
|
||||
Run: challengesView,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
challengesCmd.AddCommand(challengesViewCmd)
|
||||
|
||||
challengesLeaveCmd := &cobra.Command{
|
||||
Use: "leave <challenge id>",
|
||||
Short: "Leave a challenge",
|
||||
Run: challengesLeave,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
challengesCmd.AddCommand(challengesLeaveCmd)
|
||||
|
||||
challengesRemoveCmd := &cobra.Command{
|
||||
Use: "remove <challenge id> <user id>",
|
||||
Short: "Remove a user from a challenge",
|
||||
Run: challengesRemove,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
challengesCmd.AddCommand(challengesRemoveCmd)
|
||||
}
|
||||
|
||||
func challengesList(_ *cobra.Command, args []string) {
|
||||
challenges, err := client.AdhocChallenges()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Start", "End", "Description", "Name", "Rank")
|
||||
for _, c := range challenges {
|
||||
t.AddRow(c.UUID, c.Start, c.End, c.Description, c.Name, c.UserRanking)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func challengesListInvites(_ *cobra.Command, _ []string) {
|
||||
challenges, err := client.AdhocChallengeInvites()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Invite ID", "Challenge ID", "Start", "End", "Description", "Name", "Rank")
|
||||
for _, c := range challenges {
|
||||
t.AddRow(c.InviteID, c.UUID, c.Start, c.End, c.Description, c.Name, c.UserRanking)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func challengesAccept(_ *cobra.Command, args []string) {
|
||||
inviteID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.AdhocChallengeInvitationRespond(inviteID, true)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func challengesDecline(_ *cobra.Command, args []string) {
|
||||
inviteID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.AdhocChallengeInvitationRespond(inviteID, false)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func challengesListPrevious(_ *cobra.Command, args []string) {
|
||||
challenges, err := client.HistoricalAdhocChallenges()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Start", "End", "Description", "Name", "Rank")
|
||||
for _, c := range challenges {
|
||||
t.AddRow(c.UUID, c.Start, c.End, c.Description, c.Name, c.UserRanking)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func challengesLeave(_ *cobra.Command, args []string) {
|
||||
uuid := args[0]
|
||||
err := client.LeaveAdhocChallenge(uuid, 0)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func challengesRemove(_ *cobra.Command, args []string) {
|
||||
uuid := args[0]
|
||||
|
||||
profileID, err := strconv.ParseInt(args[1], 10, 64)
|
||||
bail(err)
|
||||
|
||||
err = client.LeaveAdhocChallenge(uuid, profileID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func challengesView(_ *cobra.Command, args []string) {
|
||||
uuid := args[0]
|
||||
challenge, err := client.AdhocChallenge(uuid)
|
||||
bail(err)
|
||||
|
||||
players := make([]string, len(challenge.Players))
|
||||
for i, player := range challenge.Players {
|
||||
players[i] = player.FullName + " [" + player.DisplayName + "]"
|
||||
}
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", challenge.UUID)
|
||||
t.AddValue("Start", challenge.Start.String())
|
||||
t.AddValue("End", challenge.End.String())
|
||||
t.AddValue("Description", challenge.Description)
|
||||
t.AddValue("Name", challenge.Name)
|
||||
t.AddValue("Rank", challenge.UserRanking)
|
||||
t.AddValue("Players", strings.Join(players, ", "))
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
38
python-garmin-connect/connect/completion.go
Normal file
38
python-garmin-connect/connect/completion.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
completionCmd := &cobra.Command{
|
||||
Use: "completion",
|
||||
}
|
||||
rootCmd.AddCommand(completionCmd)
|
||||
|
||||
completionBashCmd := &cobra.Command{
|
||||
Use: "bash",
|
||||
Short: "Output command completion for Bourne Again Shell (bash)",
|
||||
RunE: completionBash,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
completionCmd.AddCommand(completionBashCmd)
|
||||
|
||||
completionZshCmd := &cobra.Command{
|
||||
Use: "zsh",
|
||||
Short: "Output command completion for Z Shell (zsh)",
|
||||
RunE: completionZsh,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
completionCmd.AddCommand(completionZshCmd)
|
||||
}
|
||||
|
||||
func completionBash(_ *cobra.Command, _ []string) error {
|
||||
return rootCmd.GenBashCompletion(os.Stdout)
|
||||
}
|
||||
|
||||
func completionZsh(_ *cobra.Command, _ []string) error {
|
||||
return rootCmd.GenZshCompletion(os.Stdout)
|
||||
}
|
||||
180
python-garmin-connect/connect/connections.go
Normal file
180
python-garmin-connect/connect/connections.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
connectionsCmd := &cobra.Command{
|
||||
Use: "connections",
|
||||
}
|
||||
rootCmd.AddCommand(connectionsCmd)
|
||||
|
||||
connectionsListCmd := &cobra.Command{
|
||||
Use: "list [display name]",
|
||||
Short: "List all connections",
|
||||
Run: connectionsList,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsListCmd)
|
||||
|
||||
connectionsPendingCmd := &cobra.Command{
|
||||
Use: "pending",
|
||||
Short: "List pending connections",
|
||||
Run: connectionsPending,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsPendingCmd)
|
||||
|
||||
connectionsRemoveCmd := &cobra.Command{
|
||||
Use: "remove <connection ID>",
|
||||
Short: "Remove a connection",
|
||||
Run: connectionsRemove,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsRemoveCmd)
|
||||
|
||||
connectionsSearchCmd := &cobra.Command{
|
||||
Use: "search <keyword>",
|
||||
Short: "Search Garmin wide for a person",
|
||||
Run: connectionsSearch,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsSearchCmd)
|
||||
|
||||
connectionsAcceptCmd := &cobra.Command{
|
||||
Use: "accept <request id>",
|
||||
Short: "Accept a connection request",
|
||||
Run: connectionsAccept,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsAcceptCmd)
|
||||
|
||||
connectionsRequestCmd := &cobra.Command{
|
||||
Use: "request <display name>",
|
||||
Short: "Request connectio from another user",
|
||||
Run: connectionsRequest,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
connectionsCmd.AddCommand(connectionsRequestCmd)
|
||||
|
||||
blockedCmd := &cobra.Command{
|
||||
Use: "blocked",
|
||||
}
|
||||
connectionsCmd.AddCommand(blockedCmd)
|
||||
|
||||
blockedListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List currently blocked users",
|
||||
Run: connectionsBlockedList,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
blockedCmd.AddCommand(blockedListCmd)
|
||||
|
||||
blockedAddCmd := &cobra.Command{
|
||||
Use: "add <display name>",
|
||||
Short: "Add a user to the blocked list",
|
||||
Run: connectionsBlockedAdd,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
blockedCmd.AddCommand(blockedAddCmd)
|
||||
|
||||
blockedRemoveCmd := &cobra.Command{
|
||||
Use: "remove <display name>",
|
||||
Short: "Remove a user from the blocked list",
|
||||
Run: connectionsBlockedRemove,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
blockedCmd.AddCommand(blockedRemoveCmd)
|
||||
}
|
||||
|
||||
func connectionsList(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
if len(args) == 1 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
connections, err := client.Connections(displayName)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Connection ID", "Display Name", "Name", "Location", "Profile Image")
|
||||
for _, c := range connections {
|
||||
t.AddRow(c.ConnectionRequestID, c.DisplayName, c.Fullname, c.Location, c.ProfileImageURLMedium)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func connectionsPending(_ *cobra.Command, _ []string) {
|
||||
connections, err := client.PendingConnections()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("RequestID", "Display Name", "Name", "Location", "Profile Image")
|
||||
for _, c := range connections {
|
||||
t.AddRow(c.ConnectionRequestID, c.DisplayName, c.Fullname, c.Location, c.ProfileImageURLMedium)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func connectionsRemove(_ *cobra.Command, args []string) {
|
||||
connectionRequestID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.RemoveConnection(connectionRequestID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func connectionsSearch(_ *cobra.Command, args []string) {
|
||||
keyword := args[0]
|
||||
connections, err := client.SearchConnections(keyword)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
for _, c := range connections {
|
||||
t.AddValue(c.DisplayName, c.Fullname)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func connectionsAccept(_ *cobra.Command, args []string) {
|
||||
connectionRequestID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.AcceptConnection(connectionRequestID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func connectionsRequest(_ *cobra.Command, args []string) {
|
||||
displayName := args[0]
|
||||
|
||||
err := client.RequestConnection(displayName)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func connectionsBlockedList(_ *cobra.Command, _ []string) {
|
||||
blockedUsers, err := client.BlockedUsers()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Display Name", "Name", "Location", "Profile Image")
|
||||
for _, c := range blockedUsers {
|
||||
t.AddRow(c.DisplayName, c.Fullname, c.Location, c.ProfileImageURLMedium)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func connectionsBlockedAdd(_ *cobra.Command, args []string) {
|
||||
displayName := args[0]
|
||||
err := client.BlockUser(displayName)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func connectionsBlockedRemove(_ *cobra.Command, args []string) {
|
||||
displayName := args[0]
|
||||
err := client.UnblockUser(displayName)
|
||||
bail(err)
|
||||
}
|
||||
151
python-garmin-connect/connect/gear.go
Normal file
151
python-garmin-connect/connect/gear.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gearCmd := &cobra.Command{
|
||||
Use: "gear",
|
||||
}
|
||||
rootCmd.AddCommand(gearCmd)
|
||||
|
||||
gearListCmd := &cobra.Command{
|
||||
Use: "list [profile ID]",
|
||||
Short: "List Gear",
|
||||
Run: gearList,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
gearCmd.AddCommand(gearListCmd)
|
||||
|
||||
gearTypeListCmd := &cobra.Command{
|
||||
Use: "types",
|
||||
Short: "List Gear Types",
|
||||
Run: gearTypeList,
|
||||
}
|
||||
gearCmd.AddCommand(gearTypeListCmd)
|
||||
|
||||
gearLinkCommand := &cobra.Command{
|
||||
Use: "link <gear UUID> <activity id>",
|
||||
Short: "Link Gear to Activity",
|
||||
Run: gearLink,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
gearCmd.AddCommand(gearLinkCommand)
|
||||
|
||||
gearUnlinkCommand := &cobra.Command{
|
||||
Use: "unlink <gear UUID> <activity id>",
|
||||
Short: "Unlink Gear to Activity",
|
||||
Run: gearUnlink,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
gearCmd.AddCommand(gearUnlinkCommand)
|
||||
|
||||
gearForActivityCommand := &cobra.Command{
|
||||
Use: "activity <activity id>",
|
||||
Short: "Get Gear for Activity",
|
||||
Run: gearForActivity,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
gearCmd.AddCommand(gearForActivityCommand)
|
||||
}
|
||||
|
||||
func gearList(_ *cobra.Command, args []string) {
|
||||
var profileID int64 = 0
|
||||
var err error
|
||||
if len(args) == 1 {
|
||||
profileID, err = strconv.ParseInt(args[0], 10, 64)
|
||||
bail(err)
|
||||
}
|
||||
gear, err := client.Gear(profileID)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("UUID", "Type", "Brand & Model", "Nickname", "Created Date", "Total Distance", "Activities")
|
||||
for _, g := range gear {
|
||||
|
||||
gearStats, err := client.GearStats(g.Uuid)
|
||||
bail(err)
|
||||
|
||||
t.AddRow(
|
||||
g.Uuid,
|
||||
g.GearTypeName,
|
||||
g.CustomMakeModel,
|
||||
g.DisplayName,
|
||||
g.CreateDate.Time,
|
||||
strconv.FormatFloat(gearStats.TotalDistance, 'f', 2, 64),
|
||||
gearStats.TotalActivities,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func gearTypeList(_ *cobra.Command, _ []string) {
|
||||
gearTypes, err := client.GearType()
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Name", "Created Date", "Update Date")
|
||||
sort.Slice(gearTypes, func(i, j int) bool {
|
||||
return gearTypes[i].TypeID < gearTypes[j].TypeID
|
||||
})
|
||||
|
||||
for _, g := range gearTypes {
|
||||
t.AddRow(
|
||||
g.TypeID,
|
||||
g.TypeName,
|
||||
g.CreateDate,
|
||||
g.UpdateDate,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func gearLink(_ *cobra.Command, args []string) {
|
||||
uuid := args[0]
|
||||
activityID, err := strconv.Atoi(args[1])
|
||||
bail(err)
|
||||
|
||||
err = client.GearLink(uuid, activityID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func gearUnlink(_ *cobra.Command, args []string) {
|
||||
uuid := args[0]
|
||||
activityID, err := strconv.Atoi(args[1])
|
||||
bail(err)
|
||||
|
||||
err = client.GearUnlink(uuid, activityID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func gearForActivity(_ *cobra.Command, args []string) {
|
||||
activityID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
gear, err := client.GearForActivity(0, activityID)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("UUID", "Type", "Brand & Model", "Nickname", "Created Date", "Total Distance", "Activities")
|
||||
for _, g := range gear {
|
||||
|
||||
gearStats, err := client.GearStats(g.Uuid)
|
||||
bail(err)
|
||||
|
||||
t.AddRow(
|
||||
g.Uuid,
|
||||
g.GearTypeName,
|
||||
g.CustomMakeModel,
|
||||
g.DisplayName,
|
||||
g.CreateDate.Time,
|
||||
strconv.FormatFloat(gearStats.TotalDistance, 'f', 2, 64),
|
||||
gearStats.TotalActivities,
|
||||
)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
46
python-garmin-connect/connect/get.go
Normal file
46
python-garmin-connect/connect/get.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
formatJSON bool
|
||||
)
|
||||
|
||||
func init() {
|
||||
getCmd := &cobra.Command{
|
||||
Use: "get <URL>",
|
||||
Short: "Get data from Garmin Connect, print to stdout",
|
||||
Run: get,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
getCmd.Flags().BoolVarP(&formatJSON, "json", "j", false, "Format output as indented JSON")
|
||||
rootCmd.AddCommand(getCmd)
|
||||
}
|
||||
|
||||
func get(_ *cobra.Command, args []string) {
|
||||
url := args[0]
|
||||
|
||||
if formatJSON {
|
||||
raw := bytes.NewBuffer(nil)
|
||||
buffer := bytes.NewBuffer(nil)
|
||||
|
||||
err := client.Download(url, raw)
|
||||
bail(err)
|
||||
|
||||
err = json.Indent(buffer, raw.Bytes(), "", " ")
|
||||
bail(err)
|
||||
|
||||
_, err = io.Copy(os.Stdout, buffer)
|
||||
bail(err)
|
||||
} else {
|
||||
err := client.Download(url, os.Stdout)
|
||||
bail(err)
|
||||
}
|
||||
}
|
||||
67
python-garmin-connect/connect/goals.go
Normal file
67
python-garmin-connect/connect/goals.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
goalsCmd := &cobra.Command{
|
||||
Use: "goals",
|
||||
}
|
||||
rootCmd.AddCommand(goalsCmd)
|
||||
|
||||
goalsListCmd := &cobra.Command{
|
||||
Use: "list [display name]",
|
||||
Short: "List all goals",
|
||||
Run: goalsList,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
goalsCmd.AddCommand(goalsListCmd)
|
||||
|
||||
goalsDeleteCmd := &cobra.Command{
|
||||
Use: "delete <goal id>",
|
||||
Short: "Delete a goal",
|
||||
Run: goalsDelete,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
goalsCmd.AddCommand(goalsDeleteCmd)
|
||||
}
|
||||
|
||||
func goalsList(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
if len(args) == 1 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Profile", "Category", "Type", "Start", "End", "Created", "Value")
|
||||
for typ := 0; typ <= 9; typ++ {
|
||||
goals, err := client.Goals(displayName, typ)
|
||||
bail(err)
|
||||
|
||||
for _, g := range goals {
|
||||
t.AddRow(
|
||||
g.ID,
|
||||
g.ProfileID,
|
||||
g.GoalCategory,
|
||||
g.GoalType,
|
||||
g.Start,
|
||||
g.End,
|
||||
g.Created,
|
||||
g.Value,
|
||||
)
|
||||
}
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func goalsDelete(_ *cobra.Command, args []string) {
|
||||
goalID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.DeleteGoal("", goalID)
|
||||
bail(err)
|
||||
}
|
||||
189
python-garmin-connect/connect/groups.go
Normal file
189
python-garmin-connect/connect/groups.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
groupsCmd := &cobra.Command{
|
||||
Use: "groups",
|
||||
}
|
||||
rootCmd.AddCommand(groupsCmd)
|
||||
|
||||
groupsListCmd := &cobra.Command{
|
||||
Use: "list [display name]",
|
||||
Short: "List all groups",
|
||||
Run: groupsList,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
groupsCmd.AddCommand(groupsListCmd)
|
||||
|
||||
groupsViewCmd := &cobra.Command{
|
||||
Use: "view <group id>",
|
||||
Short: "View group details",
|
||||
Run: groupsView,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsCmd.AddCommand(groupsViewCmd)
|
||||
|
||||
groupsViewAnnouncementCmd := &cobra.Command{
|
||||
Use: "announcement <group id>",
|
||||
Short: "View group abbouncement",
|
||||
Run: groupsViewAnnouncement,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsViewCmd.AddCommand(groupsViewAnnouncementCmd)
|
||||
|
||||
groupsViewMembersCmd := &cobra.Command{
|
||||
Use: "members <group id>",
|
||||
Short: "View group members",
|
||||
Run: groupsViewMembers,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsViewCmd.AddCommand(groupsViewMembersCmd)
|
||||
|
||||
groupsSearchCmd := &cobra.Command{
|
||||
Use: "search <keyword>",
|
||||
Short: "Search for a group",
|
||||
Run: groupsSearch,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsCmd.AddCommand(groupsSearchCmd)
|
||||
|
||||
groupsJoinCmd := &cobra.Command{
|
||||
Use: "join <group id>",
|
||||
Short: "Join a group",
|
||||
Run: groupsJoin,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsCmd.AddCommand(groupsJoinCmd)
|
||||
|
||||
groupsLeaveCmd := &cobra.Command{
|
||||
Use: "leave <group id>",
|
||||
Short: "Leave a group",
|
||||
Run: groupsLeave,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
groupsCmd.AddCommand(groupsLeaveCmd)
|
||||
}
|
||||
|
||||
func groupsList(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
if len(args) == 1 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
groups, err := client.Groups(displayName)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Name", "Description", "Profile Image")
|
||||
for _, g := range groups {
|
||||
t.AddRow(g.ID, g.Name, g.Description, g.ProfileImageURLLarge)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func groupsSearch(_ *cobra.Command, args []string) {
|
||||
keyword := args[0]
|
||||
groups, err := client.SearchGroups(keyword)
|
||||
bail(err)
|
||||
|
||||
lastID := 0
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("ID", "Name", "Description", "Profile Image")
|
||||
for _, g := range groups {
|
||||
if g.ID == lastID {
|
||||
continue
|
||||
}
|
||||
|
||||
t.AddRow(g.ID, g.Name, g.Description, g.ProfileImageURLLarge)
|
||||
|
||||
lastID = g.ID
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func groupsView(_ *cobra.Command, args []string) {
|
||||
id, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
group, err := client.Group(id)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", group.ID)
|
||||
t.AddValue("Name", group.Name)
|
||||
t.AddValue("Description", group.Description)
|
||||
t.AddValue("OwnerID", group.OwnerID)
|
||||
t.AddValue("ProfileImageURLLarge", group.ProfileImageURLLarge)
|
||||
t.AddValue("ProfileImageURLMedium", group.ProfileImageURLMedium)
|
||||
t.AddValue("ProfileImageURLSmall", group.ProfileImageURLSmall)
|
||||
t.AddValue("Visibility", group.Visibility)
|
||||
t.AddValue("Privacy", group.Privacy)
|
||||
t.AddValue("Location", group.Location)
|
||||
t.AddValue("WebsiteURL", group.WebsiteURL)
|
||||
t.AddValue("FacebookURL", group.FacebookURL)
|
||||
t.AddValue("TwitterURL", group.TwitterURL)
|
||||
// t.AddValue("PrimaryActivities", group.PrimaryActivities)
|
||||
t.AddValue("OtherPrimaryActivity", group.OtherPrimaryActivity)
|
||||
// t.AddValue("LeaderboardTypes", group.LeaderboardTypes)
|
||||
// t.AddValue("FeatureTypes", group.FeatureTypes)
|
||||
t.AddValue("CorporateWellness", group.CorporateWellness)
|
||||
// t.AddValue("ActivityFeedTypes", group.ActivityFeedTypes)
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func groupsViewAnnouncement(_ *cobra.Command, args []string) {
|
||||
id, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
announcement, err := client.GroupAnnouncement(id)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", announcement.ID)
|
||||
t.AddValue("GroupID", announcement.GroupID)
|
||||
t.AddValue("Title", announcement.Title)
|
||||
t.AddValue("ExpireDate", announcement.ExpireDate.String())
|
||||
t.AddValue("AnnouncementDate", announcement.AnnouncementDate.String())
|
||||
t.Output(os.Stdout)
|
||||
fmt.Fprintf(os.Stdout, "\n%s\n", strings.TrimSpace(announcement.Message))
|
||||
}
|
||||
|
||||
func groupsViewMembers(_ *cobra.Command, args []string) {
|
||||
id, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
members, err := client.GroupMembers(id)
|
||||
bail(err)
|
||||
|
||||
t := NewTable()
|
||||
t.AddHeader("Display Name", "Joined", "Name", "Location", "Role", "Profile Image")
|
||||
for _, m := range members {
|
||||
t.AddRow(m.DisplayName, m.Joined, m.Fullname, m.Location, m.Role, m.ProfileImageURLMedium)
|
||||
}
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func groupsJoin(_ *cobra.Command, args []string) {
|
||||
groupID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.JoinGroup(groupID)
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func groupsLeave(_ *cobra.Command, args []string) {
|
||||
groupID, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.LeaveGroup(groupID)
|
||||
bail(err)
|
||||
}
|
||||
96
python-garmin-connect/connect/info.go
Normal file
96
python-garmin-connect/connect/info.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
infoCmd := &cobra.Command{
|
||||
Use: "info [display name]",
|
||||
Short: "Show various information and statistics about a Connect User",
|
||||
Run: info,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
rootCmd.AddCommand(infoCmd)
|
||||
}
|
||||
|
||||
func info(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
if len(args) == 1 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
t := NewTabular()
|
||||
|
||||
socialProfile, err := client.SocialProfile(displayName)
|
||||
if err == connect.ErrNotFound {
|
||||
bail(err)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
displayName = socialProfile.DisplayName
|
||||
} else {
|
||||
socialProfile, err = client.PublicSocialProfile(displayName)
|
||||
bail(err)
|
||||
|
||||
displayName = socialProfile.DisplayName
|
||||
}
|
||||
|
||||
t.AddValue("ID", socialProfile.ID)
|
||||
t.AddValue("Profile ID", socialProfile.ProfileID)
|
||||
t.AddValue("Display Name", socialProfile.DisplayName)
|
||||
t.AddValue("Name", socialProfile.Fullname)
|
||||
t.AddValue("Level", socialProfile.UserLevel)
|
||||
t.AddValue("Points", socialProfile.UserPoint)
|
||||
t.AddValue("Profile Image", socialProfile.ProfileImageURLLarge)
|
||||
|
||||
info, err := client.PersonalInformation(displayName)
|
||||
if err == nil {
|
||||
t.AddValue("", "")
|
||||
t.AddValue("Gender", info.UserInfo.Gender)
|
||||
t.AddValueUnit("Age", info.UserInfo.Age, "years")
|
||||
t.AddValueUnit("Height", nzf(info.BiometricProfile.Height), "cm")
|
||||
t.AddValueUnit("Weight", nzf(info.BiometricProfile.Weight/1000.0), "kg")
|
||||
t.AddValueUnit("Vo² Max", nzf(info.BiometricProfile.VO2Max), "mL/kg/min")
|
||||
t.AddValueUnit("Vo² Max (cycling)", nzf(info.BiometricProfile.VO2MaxCycling), "mL/kg/min")
|
||||
}
|
||||
|
||||
life, err := client.LifetimeActivities(displayName)
|
||||
if err == nil {
|
||||
t.AddValue("", "")
|
||||
t.AddValue("Activities", life.Activities)
|
||||
t.AddValueUnit("Distance", life.Distance/1000.0, "km")
|
||||
t.AddValueUnit("Time", (time.Duration(life.Duration) * time.Second).Round(time.Second).String(), "hms")
|
||||
t.AddValueUnit("Calories", life.Calories/4.184, "Kcal")
|
||||
t.AddValueUnit("Elev Gain", life.ElevationGain, "m")
|
||||
}
|
||||
|
||||
totals, err := client.LifetimeTotals(displayName)
|
||||
if err == nil {
|
||||
t.AddValue("", "")
|
||||
t.AddValueUnit("Steps", totals.Steps, "steps")
|
||||
t.AddValueUnit("Distance", totals.Distance/1000.0, "km")
|
||||
t.AddValueUnit("Daily Goal Met", totals.GoalsMetInDays, "days")
|
||||
t.AddValueUnit("Active Days", totals.ActiveDays, "days")
|
||||
if totals.ActiveDays > 0 {
|
||||
t.AddValueUnit("Average Steps", totals.Steps/totals.ActiveDays, "steps")
|
||||
}
|
||||
t.AddValueUnit("Calories", totals.Calories, "kCal")
|
||||
}
|
||||
|
||||
lastUsed, err := client.LastUsed(displayName)
|
||||
if err == nil {
|
||||
t.AddValue("", "")
|
||||
t.AddValue("Device ID", lastUsed.DeviceID)
|
||||
t.AddValue("Device", lastUsed.DeviceName)
|
||||
t.AddValue("Time", lastUsed.DeviceUploadTime.String())
|
||||
t.AddValue("Ago", time.Since(lastUsed.DeviceUploadTime.Time).Round(time.Second).String())
|
||||
t.AddValue("Image", lastUsed.ImageURL)
|
||||
}
|
||||
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
96
python-garmin-connect/connect/main.go
Normal file
96
python-garmin-connect/connect/main.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
)
|
||||
|
||||
var (
|
||||
rootCmd = &cobra.Command{
|
||||
Use: os.Args[0] + " [command]",
|
||||
Short: "CLI Client for Garmin Connect",
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
loadState()
|
||||
if verbose {
|
||||
logger := log.New(os.Stderr, "", log.Ldate|log.Ltime|log.Lmicroseconds|log.Lshortfile)
|
||||
client.SetOptions(connect.DebugLogger(logger))
|
||||
}
|
||||
|
||||
if dumpFile != "" {
|
||||
w, err := os.OpenFile(dumpFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0600)
|
||||
bail(err)
|
||||
client.SetOptions(connect.DumpWriter(w))
|
||||
}
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
storeState()
|
||||
},
|
||||
}
|
||||
|
||||
verbose bool
|
||||
dumpFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose debug output")
|
||||
rootCmd.PersistentFlags().StringVarP(&dumpFile, "dump", "d", "", "File to dump requests and responses to")
|
||||
|
||||
authenticateCmd := &cobra.Command{
|
||||
Use: "authenticate [email]",
|
||||
Short: "Authenticate against the Garmin API",
|
||||
Run: authenticate,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
rootCmd.AddCommand(authenticateCmd)
|
||||
|
||||
signoutCmd := &cobra.Command{
|
||||
Use: "signout",
|
||||
Short: "Log out of the Garmin API and forget session and password",
|
||||
Run: signout,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
rootCmd.AddCommand(signoutCmd)
|
||||
}
|
||||
|
||||
func bail(err error) {
|
||||
if err != nil {
|
||||
log.Fatalf("%s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
bail(rootCmd.Execute())
|
||||
}
|
||||
|
||||
func authenticate(_ *cobra.Command, args []string) {
|
||||
var email string
|
||||
if len(args) == 1 {
|
||||
email = args[0]
|
||||
} else {
|
||||
fmt.Print("Email: ")
|
||||
fmt.Scanln(&email)
|
||||
}
|
||||
|
||||
fmt.Print("Password: ")
|
||||
|
||||
password, err := terminal.ReadPassword(syscall.Stdin)
|
||||
bail(err)
|
||||
|
||||
client.SetOptions(connect.Credentials(email, string(password)))
|
||||
err = client.Authenticate()
|
||||
bail(err)
|
||||
|
||||
fmt.Printf("\nSuccess\n")
|
||||
}
|
||||
|
||||
func signout(_ *cobra.Command, _ []string) {
|
||||
_ = client.Signout()
|
||||
client.Password = ""
|
||||
}
|
||||
16
python-garmin-connect/connect/nzf.go
Normal file
16
python-garmin-connect/connect/nzf.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// nzf is a type that will print "-" instead of 0.0 when used as a stringer.
|
||||
type nzf float64
|
||||
|
||||
func (nzf nzf) String() string {
|
||||
if nzf != 0.0 {
|
||||
return fmt.Sprintf("%.01f", nzf)
|
||||
}
|
||||
|
||||
return "-"
|
||||
}
|
||||
62
python-garmin-connect/connect/sleep.go
Normal file
62
python-garmin-connect/connect/sleep.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
sleepCmd := &cobra.Command{
|
||||
Use: "sleep",
|
||||
}
|
||||
rootCmd.AddCommand(sleepCmd)
|
||||
|
||||
sleepSummaryCmd := &cobra.Command{
|
||||
Use: "summary <date> [displayName]",
|
||||
Short: "Show sleep summary for date",
|
||||
Run: sleepSummary,
|
||||
Args: cobra.RangeArgs(1, 2),
|
||||
}
|
||||
sleepCmd.AddCommand(sleepSummaryCmd)
|
||||
}
|
||||
|
||||
func sleepSummary(_ *cobra.Command, args []string) {
|
||||
date, err := connect.ParseDate(args[0])
|
||||
bail(err)
|
||||
|
||||
displayName := ""
|
||||
|
||||
if len(args) > 1 {
|
||||
displayName = args[1]
|
||||
}
|
||||
|
||||
summary, _, levels, err := client.SleepData(displayName, date.Time())
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("Start", summary.StartGMT)
|
||||
t.AddValue("End", summary.EndGMT)
|
||||
t.AddValue("Sleep", hoursAndMinutes(summary.Sleep))
|
||||
t.AddValue("Nap", hoursAndMinutes(summary.Nap))
|
||||
t.AddValue("Unmeasurable", hoursAndMinutes(summary.Unmeasurable))
|
||||
t.AddValue("Deep", hoursAndMinutes(summary.Deep))
|
||||
t.AddValue("Light", hoursAndMinutes(summary.Light))
|
||||
t.AddValue("REM", hoursAndMinutes(summary.REM))
|
||||
t.AddValue("Awake", hoursAndMinutes(summary.Awake))
|
||||
t.AddValue("Confirmed", summary.Confirmed)
|
||||
t.AddValue("Confirmation Type", summary.Confirmation)
|
||||
t.AddValue("REM Data", summary.REMData)
|
||||
t.Output(os.Stdout)
|
||||
|
||||
fmt.Fprintf(os.Stdout, "\n")
|
||||
|
||||
t2 := NewTable()
|
||||
t2.AddHeader("Start", "End", "State", "Duration")
|
||||
for _, l := range levels {
|
||||
t2.AddRow(l.Start, l.End, l.State, hoursAndMinutes(l.End.Sub(l.Start.Time)))
|
||||
}
|
||||
t2.Output(os.Stdout)
|
||||
}
|
||||
57
python-garmin-connect/connect/state.go
Normal file
57
python-garmin-connect/connect/state.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
)
|
||||
|
||||
var (
|
||||
client = connect.NewClient(
|
||||
connect.AutoRenewSession(true),
|
||||
)
|
||||
|
||||
stateFile string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&stateFile, "state", "s", stateFilename(), "State file to use")
|
||||
}
|
||||
|
||||
func stateFilename() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Fatalf("Could not detect home directory: %s", err.Error())
|
||||
}
|
||||
|
||||
return path.Join(home, ".garmin-connect.json")
|
||||
}
|
||||
|
||||
func loadState() {
|
||||
data, err := ioutil.ReadFile(stateFile)
|
||||
if err != nil {
|
||||
log.Printf("Could not open state file: %s", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
err = json.Unmarshal(data, client)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not unmarshal state: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func storeState() {
|
||||
b, err := json.MarshalIndent(client, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Could not marshal state: %s", err.Error())
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(stateFile, b, 0600)
|
||||
if err != nil {
|
||||
log.Fatalf("Could not write state file: %s", err.Error())
|
||||
}
|
||||
}
|
||||
70
python-garmin-connect/connect/tools.go
Normal file
70
python-garmin-connect/connect/tools.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
if t == (time.Time{}) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%04d-%02d-%02d", t.Year(), t.Month(), t.Day())
|
||||
}
|
||||
|
||||
func stringer(value interface{}) string {
|
||||
stringer, ok := value.(fmt.Stringer)
|
||||
if ok {
|
||||
return stringer.String()
|
||||
}
|
||||
|
||||
str := ""
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
str = v
|
||||
case int, int64:
|
||||
str = fmt.Sprintf("%d", v)
|
||||
case float64:
|
||||
str = strconv.FormatFloat(v, 'f', 1, 64)
|
||||
case bool:
|
||||
if v {
|
||||
str = gotIt
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("no idea what to do about %T:%v", value, value))
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
func sliceStringer(values []interface{}) []string {
|
||||
ret := make([]string, len(values))
|
||||
|
||||
for i, value := range values {
|
||||
ret[i] = stringer(value)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func hoursAndMinutes(dur time.Duration) string {
|
||||
if dur == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
if dur < 60*time.Minute {
|
||||
m := dur.Truncate(time.Minute)
|
||||
|
||||
return fmt.Sprintf("%dm", m/time.Minute)
|
||||
}
|
||||
|
||||
h := dur.Truncate(time.Hour)
|
||||
m := (dur - h).Truncate(time.Minute)
|
||||
|
||||
h /= time.Hour
|
||||
m /= time.Minute
|
||||
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
224
python-garmin-connect/connect/weight.go
Normal file
224
python-garmin-connect/connect/weight.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
connect "github.com/abrander/garmin-connect"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
weightCmd := &cobra.Command{
|
||||
Use: "weight",
|
||||
}
|
||||
rootCmd.AddCommand(weightCmd)
|
||||
|
||||
weightLatestCmd := &cobra.Command{
|
||||
Use: "latest",
|
||||
Short: "Show the latest weight-in",
|
||||
Run: weightLatest,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
weightCmd.AddCommand(weightLatestCmd)
|
||||
|
||||
weightLatestWeekCmd := &cobra.Command{
|
||||
Use: "week",
|
||||
Short: "Show average weight for the latest week",
|
||||
Run: weightLatestWeek,
|
||||
Args: cobra.NoArgs,
|
||||
}
|
||||
weightLatestCmd.AddCommand(weightLatestWeekCmd)
|
||||
|
||||
weightAddCmd := &cobra.Command{
|
||||
Use: "add <yyyy-mm-dd> <weight in grams>",
|
||||
Short: "Add a simple weight for a specific date",
|
||||
Run: weightAdd,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
weightCmd.AddCommand(weightAddCmd)
|
||||
|
||||
weightDeleteCmd := &cobra.Command{
|
||||
Use: "delete <yyyy-mm-dd]>",
|
||||
Short: "Delete a weight-in",
|
||||
Run: weightDelete,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
weightCmd.AddCommand(weightDeleteCmd)
|
||||
|
||||
weightDateCmd := &cobra.Command{
|
||||
Use: "date [yyyy-mm-dd]",
|
||||
Short: "Show weight for a specific date",
|
||||
Run: weightDate,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
weightCmd.AddCommand(weightDateCmd)
|
||||
|
||||
weightRangeCmd := &cobra.Command{
|
||||
Use: "range [yyyy-mm-dd] [yyyy-mm-dd]",
|
||||
Short: "Show weight for a date range",
|
||||
Run: weightRange,
|
||||
Args: cobra.ExactArgs(2),
|
||||
}
|
||||
weightCmd.AddCommand(weightRangeCmd)
|
||||
|
||||
weightGoalCmd := &cobra.Command{
|
||||
Use: "goal [displayName]",
|
||||
Short: "Show weight goal",
|
||||
Run: weightGoal,
|
||||
Args: cobra.RangeArgs(0, 1),
|
||||
}
|
||||
weightCmd.AddCommand(weightGoalCmd)
|
||||
|
||||
weightGoalSetCmd := &cobra.Command{
|
||||
Use: "set [goal in gram]",
|
||||
Short: "Set weight goal",
|
||||
Run: weightGoalSet,
|
||||
Args: cobra.ExactArgs(1),
|
||||
}
|
||||
weightGoalCmd.AddCommand(weightGoalSetCmd)
|
||||
}
|
||||
|
||||
func weightLatest(_ *cobra.Command, _ []string) {
|
||||
weightin, err := client.LatestWeight(time.Now())
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("Date", weightin.Date.String())
|
||||
t.AddValueUnit("Weight", weightin.Weight/1000.0, "kg")
|
||||
t.AddValueUnit("BMI", weightin.BMI, "kg/m2")
|
||||
t.AddValueUnit("Fat", weightin.BodyFatPercentage, "%")
|
||||
t.AddValueUnit("Fat Mass", (weightin.Weight*weightin.BodyFatPercentage)/100000.0, "kg")
|
||||
t.AddValueUnit("Water", weightin.BodyWater, "%")
|
||||
t.AddValueUnit("Bone Mass", float64(weightin.BoneMass)/1000.0, "kg")
|
||||
t.AddValueUnit("Muscle Mass", float64(weightin.MuscleMass)/1000.0, "kg")
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func weightLatestWeek(_ *cobra.Command, _ []string) {
|
||||
now := time.Now()
|
||||
from := time.Now().Add(-24 * 6 * time.Hour)
|
||||
|
||||
average, _, err := client.Weightins(from, now)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("Average from", formatDate(from))
|
||||
t.AddValueUnit("Weight", average.Weight/1000.0, "kg")
|
||||
t.AddValueUnit("BMI", average.BMI, "kg/m2")
|
||||
t.AddValueUnit("Fat", average.BodyFatPercentage, "%")
|
||||
t.AddValueUnit("Fat Mass", (average.Weight*average.BodyFatPercentage)/100000.0, "kg")
|
||||
t.AddValueUnit("Water", average.BodyWater, "%")
|
||||
t.AddValueUnit("Bone Mass", float64(average.BoneMass)/1000.0, "kg")
|
||||
t.AddValueUnit("Muscle Mass", float64(average.MuscleMass)/1000.0, "kg")
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func weightAdd(_ *cobra.Command, args []string) {
|
||||
date, err := connect.ParseDate(args[0])
|
||||
bail(err)
|
||||
|
||||
weight, err := strconv.Atoi(args[1])
|
||||
bail(err)
|
||||
|
||||
err = client.AddUserWeight(date.Time(), float64(weight))
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func weightDelete(_ *cobra.Command, args []string) {
|
||||
date, err := connect.ParseDate(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.DeleteWeightin(date.Time())
|
||||
bail(err)
|
||||
}
|
||||
|
||||
func weightDate(_ *cobra.Command, args []string) {
|
||||
date, err := connect.ParseDate(args[0])
|
||||
bail(err)
|
||||
|
||||
tim, weight, err := client.WeightByDate(date.Time())
|
||||
bail(err)
|
||||
|
||||
zero := time.Time{}
|
||||
if tim.Time == zero {
|
||||
fmt.Printf("No weight ins on this date\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("Time", tim.String())
|
||||
t.AddValueUnit("Weight", weight/1000.0, "kg")
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func weightRange(_ *cobra.Command, args []string) {
|
||||
from, err := connect.ParseDate(args[0])
|
||||
bail(err)
|
||||
|
||||
to, err := connect.ParseDate(args[1])
|
||||
bail(err)
|
||||
|
||||
average, weightins, err := client.Weightins(from.Time(), to.Time())
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
|
||||
t.AddValueUnit("Weight", average.Weight/1000.0, "kg")
|
||||
t.AddValueUnit("BMI", average.BMI, "kg/m2")
|
||||
t.AddValueUnit("Fat", average.BodyFatPercentage, "%")
|
||||
t.AddValueUnit("Fat Mass", average.Weight*average.BodyFatPercentage/100000.0, "kg")
|
||||
t.AddValueUnit("Water", average.BodyWater, "%")
|
||||
t.AddValueUnit("Bone Mass", float64(average.BoneMass)/1000.0, "kg")
|
||||
t.AddValueUnit("Muscle Mass", float64(average.MuscleMass)/1000.0, "kg")
|
||||
fmt.Fprintf(os.Stdout, " \033[1mAverage\033[0m\n")
|
||||
t.Output(os.Stdout)
|
||||
|
||||
t2 := NewTable()
|
||||
t2.AddHeader("Date", "Weight", "BMI", "Fat%", "Fat", "Water%", "Bone Mass", "Muscle Mass")
|
||||
for _, weightin := range weightins {
|
||||
if weightin.Weight < 1.0 {
|
||||
continue
|
||||
}
|
||||
|
||||
t2.AddRow(
|
||||
weightin.Date,
|
||||
weightin.Weight/1000.0,
|
||||
nzf(weightin.BMI),
|
||||
nzf(weightin.BodyFatPercentage),
|
||||
nzf(weightin.Weight*weightin.BodyFatPercentage/100000.0),
|
||||
nzf(weightin.BodyWater),
|
||||
nzf(float64(weightin.BoneMass)/1000.0),
|
||||
nzf(float64(weightin.MuscleMass)/1000.0),
|
||||
)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "\n")
|
||||
t2.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func weightGoal(_ *cobra.Command, args []string) {
|
||||
displayName := ""
|
||||
|
||||
if len(args) > 0 {
|
||||
displayName = args[0]
|
||||
}
|
||||
|
||||
goal, err := client.WeightGoal(displayName)
|
||||
bail(err)
|
||||
|
||||
t := NewTabular()
|
||||
t.AddValue("ID", goal.ID)
|
||||
t.AddValue("Created", goal.Created)
|
||||
t.AddValueUnit("Target", float64(goal.Value)/1000.0, "kg")
|
||||
t.Output(os.Stdout)
|
||||
}
|
||||
|
||||
func weightGoalSet(_ *cobra.Command, args []string) {
|
||||
goal, err := strconv.Atoi(args[0])
|
||||
bail(err)
|
||||
|
||||
err = client.SetWeightGoal(goal)
|
||||
bail(err)
|
||||
}
|
||||
4
python-garmin-connect/doc.go
Normal file
4
python-garmin-connect/doc.go
Normal file
@@ -0,0 +1,4 @@
|
||||
// Package connect provides access to the unofficial Garmin Connect API. This
|
||||
// is not supported or endorsed by Garmin Ltd. The API may change or stop
|
||||
// working at any time. Please use responsible.
|
||||
package connect
|
||||
8
python-garmin-connect/go.mod
Normal file
8
python-garmin-connect/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module github.com/abrander/garmin-connect
|
||||
|
||||
go 1.15
|
||||
|
||||
require (
|
||||
github.com/spf13/cobra v1.1.1
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad
|
||||
)
|
||||
292
python-garmin-connect/go.sum
Normal file
292
python-garmin-connect/go.sum
Normal file
@@ -0,0 +1,292 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
|
||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
|
||||
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
|
||||
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193GlqGZbnPFnPV/5Rsb4=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
37
python-garmin-connect/tools.go
Normal file
37
python-garmin-connect/tools.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package connect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// date formats a time.Time as a date usable in the Garmin Connect API.
|
||||
func formatDate(t time.Time) string {
|
||||
return fmt.Sprintf("%04d-%02d-%02d", t.Year(), t.Month(), t.Day())
|
||||
}
|
||||
|
||||
// drainBody reads all of b to memory and then returns two equivalent
|
||||
// ReadClosers yielding the same bytes.
|
||||
//
|
||||
// It returns an error if the initial slurp of all bytes fails. It does not attempt
|
||||
// to make the returned ReadClosers have identical error-matching behavior.
|
||||
//
|
||||
// Liberated from net/http/httputil/dump.go.
|
||||
func drainBody(b io.ReadCloser) (r1, r2 io.ReadCloser, err error) {
|
||||
if b == http.NoBody {
|
||||
// No copying needed. Preserve the magic sentinel meaning of NoBody.
|
||||
return http.NoBody, http.NoBody, nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err = buf.ReadFrom(b); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
if err = b.Close(); err != nil {
|
||||
return nil, b, err
|
||||
}
|
||||
return ioutil.NopCloser(&buf), ioutil.NopCloser(bytes.NewReader(buf.Bytes())), nil
|
||||
}
|
||||
19
shared/interfaces/api_client.go
Normal file
19
shared/interfaces/api_client.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
types "go-garth/internal/models/types"
|
||||
"go-garth/internal/users"
|
||||
)
|
||||
|
||||
// APIClient defines the interface for making API calls that data packages need.
|
||||
type APIClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
GetUsername() string
|
||||
GetUserSettings() (*users.UserSettings, error)
|
||||
GetUserProfile() (*types.UserProfile, error)
|
||||
GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error)
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
package data
|
||||
package interfaces
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/utils"
|
||||
)
|
||||
|
||||
// Data defines the interface for Garmin Connect data types.
|
||||
// Data defines the interface for Garmin Connect data models.
|
||||
// Concrete data types (BodyBattery, HRV, Sleep, etc.) must implement this interface.
|
||||
//
|
||||
// The Get method retrieves data for a single day.
|
||||
// The List method concurrently retrieves data for a range of days.
|
||||
type Data interface {
|
||||
Get(day time.Time, c *client.Client) (interface{}, error)
|
||||
List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, error)
|
||||
Get(day time.Time, c APIClient) (interface{}, error)
|
||||
List(end time.Time, days int, c APIClient, maxWorkers int) ([]interface{}, []error)
|
||||
}
|
||||
|
||||
// BaseData provides a reusable implementation for data types to embed.
|
||||
@@ -25,8 +24,8 @@ type Data interface {
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// type BodyBatteryData struct {
|
||||
// data.BaseData
|
||||
// type BodyBatteryData {
|
||||
// interfaces.BaseData
|
||||
// // ... additional fields
|
||||
// }
|
||||
//
|
||||
@@ -36,18 +35,18 @@ type Data interface {
|
||||
// return bb
|
||||
// }
|
||||
//
|
||||
// func (bb *BodyBatteryData) get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
// func (bb *BodyBatteryData) get(day time.Time, c APIClient) (interface{}, error) {
|
||||
// // Implementation specific to body battery data
|
||||
// }
|
||||
type BaseData struct {
|
||||
// GetFunc must be set by concrete types to implement the Get method.
|
||||
// This function pointer allows BaseData to call the concrete implementation.
|
||||
GetFunc func(day time.Time, c *client.Client) (interface{}, error)
|
||||
GetFunc func(day time.Time, c APIClient) (interface{}, error)
|
||||
}
|
||||
|
||||
// Get implements the Data interface by calling the configured GetFunc.
|
||||
// Returns an error if GetFunc is not set.
|
||||
func (b *BaseData) Get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
func (b *BaseData) Get(day time.Time, c APIClient) (interface{}, error) {
|
||||
if b.GetFunc == nil {
|
||||
return nil, errors.New("GetFunc not implemented for this data type")
|
||||
}
|
||||
@@ -69,7 +68,7 @@ func (b *BaseData) Get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
//
|
||||
// []interface{}: Slice of results (order matches date range)
|
||||
// []error: Slice of errors encountered during processing
|
||||
func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) {
|
||||
func (b *BaseData) List(end time.Time, days int, c APIClient, maxWorkers int) ([]interface{}, []error) {
|
||||
if maxWorkers < 1 {
|
||||
maxWorkers = 10 // Match Python's MAX_WORKERS
|
||||
}
|
||||
425
v02.md
Normal file
425
v02.md
Normal file
@@ -0,0 +1,425 @@
|
||||
# VO2 Max Implementation Guide
|
||||
|
||||
## Overview
|
||||
This guide will help you implement VO2 max data retrieval in the go-garth Garmin Connect API client. VO2 max is a fitness metric that represents the maximum amount of oxygen consumption during exercise.
|
||||
|
||||
## Background
|
||||
Based on analysis of existing Garmin Connect libraries:
|
||||
- **Python garth**: Retrieves VO2 max via `UserSettings.get().user_data.vo_2_max_running/cycling`
|
||||
- **Go garmin-connect**: Retrieves via `PersonalInformation().BiometricProfile.VO2Max/VO2MaxCycling`
|
||||
|
||||
Our implementation will follow the Python approach since we already have a `UserSettings` structure.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 1. Update `internal/types/garmin.go`
|
||||
**What to do**: Add enhanced VO2 max types to support comprehensive VO2 max data.
|
||||
|
||||
**Location**: Add these structs to the file (around line 120, after the existing `VO2MaxData` struct):
|
||||
|
||||
```go
|
||||
// Replace the existing VO2MaxData struct with this enhanced version
|
||||
type VO2MaxData struct {
|
||||
Date time.Time `json:"calendarDate"`
|
||||
VO2MaxRunning *float64 `json:"vo2MaxRunning"`
|
||||
VO2MaxCycling *float64 `json:"vo2MaxCycling"`
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
}
|
||||
|
||||
// Add these new structs
|
||||
type VO2MaxEntry struct {
|
||||
Value float64 `json:"value"`
|
||||
ActivityType string `json:"activityType"` // "running" or "cycling"
|
||||
Date time.Time `json:"date"`
|
||||
Source string `json:"source"` // "user_settings", "activity", etc.
|
||||
}
|
||||
|
||||
type VO2MaxProfile struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
Running *VO2MaxEntry `json:"running"`
|
||||
Cycling *VO2MaxEntry `json:"cycling"`
|
||||
History []VO2MaxEntry `json:"history,omitempty"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Create `internal/data/vo2max.go`
|
||||
**What to do**: Create a new file to handle VO2 max data retrieval.
|
||||
|
||||
**Create new file** with this content:
|
||||
|
||||
```go
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/types"
|
||||
)
|
||||
|
||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||
type VO2MaxData struct {
|
||||
BaseData
|
||||
}
|
||||
|
||||
// NewVO2MaxData creates a new VO2MaxData instance
|
||||
func NewVO2MaxData() *VO2MaxData {
|
||||
vo2 := &VO2MaxData{}
|
||||
vo2.GetFunc = vo2.get
|
||||
return vo2
|
||||
}
|
||||
|
||||
// get implements the specific VO2 max data retrieval logic
|
||||
func (v *VO2MaxData) get(day time.Time, c *client.Client) (interface{}, error) {
|
||||
// Primary approach: Get from user settings (most reliable)
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Extract VO2 max data from user settings
|
||||
vo2Profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// If no VO2 max data found, still return valid empty profile
|
||||
return vo2Profile, nil
|
||||
}
|
||||
|
||||
// List implements concurrent fetching for multiple days
|
||||
// Note: VO2 max typically doesn't change daily, so this returns the same values
|
||||
func (v *VO2MaxData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) {
|
||||
// For VO2 max, we want current values from user settings
|
||||
vo2Data, err := v.get(end, c)
|
||||
if err != nil {
|
||||
return nil, []error{err}
|
||||
}
|
||||
|
||||
// Return the same VO2 max data for all requested days
|
||||
results := make([]interface{}, days)
|
||||
for i := 0; i < days; i++ {
|
||||
results[i] = vo2Data
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
||||
func GetCurrentVO2Max(c *client.Client) (*types.VO2MaxProfile, error) {
|
||||
vo2Data := NewVO2MaxData()
|
||||
result, err := vo2Data.get(time.Now(), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vo2Profile, ok := result.(*types.VO2MaxProfile)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected result type")
|
||||
}
|
||||
|
||||
return vo2Profile, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update `internal/api/client/client.go`
|
||||
**What to do**: Improve the existing `GetVO2MaxData` method and add convenience methods.
|
||||
|
||||
**Find the existing `GetVO2MaxData` method** (around line 450) and replace it with:
|
||||
|
||||
```go
|
||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
||||
// Get user settings which contains current VO2 max values
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Create VO2MaxData for the date range
|
||||
var results []types.VO2MaxData
|
||||
current := startDate
|
||||
for !current.After(endDate) {
|
||||
vo2Data := types.VO2MaxData{
|
||||
Date: current,
|
||||
UserProfilePK: settings.ID,
|
||||
}
|
||||
|
||||
// Set VO2 max values if available
|
||||
if settings.UserData.VO2MaxRunning != nil {
|
||||
vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning
|
||||
}
|
||||
if settings.UserData.VO2MaxCycling != nil {
|
||||
vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling
|
||||
}
|
||||
|
||||
results = append(results, vo2Data)
|
||||
current = current.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
||||
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Create `internal/data/vo2max_test.go`
|
||||
**What to do**: Create tests to ensure the VO2 max functionality works correctly.
|
||||
|
||||
**Create new file**:
|
||||
|
||||
```go
|
||||
package data
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-garth/internal/api/client"
|
||||
"go-garth/internal/types"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockClient for testing
|
||||
type MockClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockClient) GetUserSettings() (*client.UserSettings, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(*client.UserSettings), args.Error(1)
|
||||
}
|
||||
|
||||
func TestVO2MaxData_Get(t *testing.T) {
|
||||
// Setup mock client
|
||||
mockClient := &MockClient{}
|
||||
|
||||
// Mock user settings with VO2 max data
|
||||
runningVO2 := 45.0
|
||||
cyclingVO2 := 50.0
|
||||
mockSettings := &client.UserSettings{
|
||||
ID: 12345,
|
||||
UserData: client.UserData{
|
||||
VO2MaxRunning: &runningVO2,
|
||||
VO2MaxCycling: &cyclingVO2,
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("GetUserSettings").Return(mockSettings, nil)
|
||||
|
||||
// Test the VO2MaxData.get method
|
||||
vo2Data := NewVO2MaxData()
|
||||
result, err := vo2Data.get(time.Now(), mockClient)
|
||||
|
||||
// Assertions
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
profile, ok := result.(*types.VO2MaxProfile)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 12345, profile.UserProfilePK)
|
||||
assert.NotNil(t, profile.Running)
|
||||
assert.Equal(t, 45.0, profile.Running.Value)
|
||||
assert.Equal(t, "running", profile.Running.ActivityType)
|
||||
assert.NotNil(t, profile.Cycling)
|
||||
assert.Equal(t, 50.0, profile.Cycling.Value)
|
||||
assert.Equal(t, "cycling", profile.Cycling.ActivityType)
|
||||
}
|
||||
|
||||
func TestGetCurrentVO2Max(t *testing.T) {
|
||||
// Similar test for the convenience function
|
||||
mockClient := &MockClient{}
|
||||
|
||||
runningVO2 := 48.0
|
||||
mockSettings := &client.UserSettings{
|
||||
ID: 67890,
|
||||
UserData: client.UserData{
|
||||
VO2MaxRunning: &runningVO2,
|
||||
VO2MaxCycling: nil, // No cycling data
|
||||
},
|
||||
}
|
||||
|
||||
mockClient.On("GetUserSettings").Return(mockSettings, nil)
|
||||
|
||||
result, err := GetCurrentVO2Max(mockClient)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
assert.Equal(t, 67890, result.UserProfilePK)
|
||||
assert.NotNil(t, result.Running)
|
||||
assert.Equal(t, 48.0, result.Running.Value)
|
||||
assert.Nil(t, result.Cycling) // Should be nil since no cycling data
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Update Types
|
||||
1. Open `internal/types/garmin.go`
|
||||
2. Find the existing `VO2MaxData` struct (around line 120)
|
||||
3. Replace it with the enhanced version provided above
|
||||
4. Add the new `VO2MaxEntry` and `VO2MaxProfile` structs
|
||||
|
||||
### Step 2: Create VO2 Max Data Handler
|
||||
1. Create the new file `internal/data/vo2max.go`
|
||||
2. Copy the entire content provided above
|
||||
3. Make sure imports are correct
|
||||
|
||||
### Step 3: Update Client Methods
|
||||
1. Open `internal/api/client/client.go`
|
||||
2. Find the existing `GetVO2MaxData` method
|
||||
3. Replace it with the improved version
|
||||
4. Add the new `GetCurrentVO2Max` method
|
||||
|
||||
### Step 4: Create Tests
|
||||
1. Create `internal/data/vo2max_test.go`
|
||||
2. Add the test content provided above
|
||||
3. Install testify if not already available: `go get github.com/stretchr/testify`
|
||||
|
||||
### Step 5: Verify Implementation
|
||||
Run these commands to verify everything works:
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
go test ./internal/data/
|
||||
|
||||
# Build the project
|
||||
go build ./...
|
||||
|
||||
# Test the functionality (if you have credentials set up)
|
||||
go test -v ./internal/api/client/ -run TestClient_Login_Functional
|
||||
```
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
The implementation uses these Garmin Connect API endpoints:
|
||||
|
||||
1. **Primary**: `/userprofile-service/userprofile/user-settings`
|
||||
- Contains current VO2 max values for running and cycling
|
||||
- Most reliable source of VO2 max data
|
||||
|
||||
2. **Alternative**: `/wellness-service/wellness/daily/vo2max/{date}`
|
||||
- May contain historical VO2 max data
|
||||
- Not always available or accessible
|
||||
|
||||
## Usage Examples
|
||||
|
||||
After implementation, developers can use the VO2 max functionality like this:
|
||||
|
||||
```go
|
||||
// Get current VO2 max values
|
||||
profile, err := client.GetCurrentVO2Max()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if profile.Running != nil {
|
||||
fmt.Printf("Running VO2 Max: %.1f\n", profile.Running.Value)
|
||||
}
|
||||
if profile.Cycling != nil {
|
||||
fmt.Printf("Cycling VO2 Max: %.1f\n", profile.Cycling.Value)
|
||||
}
|
||||
|
||||
// Get VO2 max data for a date range
|
||||
start := time.Now().AddDate(0, 0, -7)
|
||||
end := time.Now()
|
||||
vo2Data, err := client.GetVO2MaxData(start, end)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
for _, data := range vo2Data {
|
||||
fmt.Printf("Date: %s, Running: %v, Cycling: %v\n",
|
||||
data.Date.Format("2006-01-02"),
|
||||
data.VO2MaxRunning,
|
||||
data.VO2MaxCycling)
|
||||
}
|
||||
```
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: "GetUserSettings method not found"
|
||||
**Solution**: Make sure you've properly implemented the `GetUserSettings` method in `internal/api/client/settings.go`. The method already exists but verify it's working correctly.
|
||||
|
||||
### Issue 2: "VO2 max values are nil"
|
||||
**Solution**: This is normal if the user hasn't done any activities that would calculate VO2 max. The code handles this gracefully by checking for nil values.
|
||||
|
||||
### Issue 3: Import errors
|
||||
**Solution**: Run `go mod tidy` to ensure all dependencies are properly managed.
|
||||
|
||||
### Issue 4: Test failures
|
||||
**Solution**: Make sure you have the testify package installed and that the mock interfaces match the actual client interface.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**: Test the data parsing and type conversion logic
|
||||
2. **Integration Tests**: Test with real Garmin Connect API calls (if credentials available)
|
||||
3. **Mock Tests**: Test error handling and edge cases with mocked responses
|
||||
|
||||
## Notes for Code Review
|
||||
|
||||
When reviewing this implementation:
|
||||
- ✅ Check that nil pointer dereferences are avoided
|
||||
- ✅ Verify proper error handling throughout
|
||||
- ✅ Ensure the API follows existing patterns in the codebase
|
||||
- ✅ Confirm that the VO2 max data structure matches Garmin's API response format
|
||||
- ✅ Test with users who have both running and cycling VO2 max data
|
||||
- ✅ Test with users who have no VO2 max data
|
||||
Reference in New Issue
Block a user