sync - build broken

This commit is contained in:
2025-09-20 15:21:49 -07:00
parent c1993ba022
commit 626c473b01
94 changed files with 8471 additions and 1053 deletions

View File

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

View File

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

View File

@@ -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
View 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
View 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.

BIN
garth

Binary file not shown.

9
go.mod
View File

@@ -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
View File

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

View File

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

View File

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

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

View File

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

View File

@@ -10,7 +10,7 @@ import (
"time"
"go-garth/internal/auth/oauth"
"go-garth/internal/types"
types "go-garth/internal/models/types"
)
var (

View File

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

View File

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

View File

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

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

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

View File

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

View 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"`
}

View File

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

View File

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

View File

@@ -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
View File

@@ -0,0 +1,6 @@
{
"name": "go-garth",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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.

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

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

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

View 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{}) {
}

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

View 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
```

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

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

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

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

View 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())
}
}

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

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

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

View File

@@ -0,0 +1 @@
/connect

View File

@@ -0,0 +1 @@
This is a simple CLI client for Garmin Connect.

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

View 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())
}
}

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

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

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

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

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

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

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

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

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

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

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

View 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 = ""
}

View 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 "-"
}

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

View 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())
}
}

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

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

View 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

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

View 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=

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

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

View File

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