mirror of
https://github.com/sstent/go-garminconnect.git
synced 2026-01-25 16:42:32 +00:00
309 lines
8.4 KiB
Go
309 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/sstent/go-garminconnect/internal/fit"
|
|
)
|
|
|
|
// Activity represents a Garmin Connect activity
|
|
type Activity struct {
|
|
ActivityID int64 `json:"activityId"`
|
|
Name string `json:"activityName"`
|
|
Type string `json:"activityType"`
|
|
StartTime time.Time `json:"startTimeLocal"`
|
|
Duration float64 `json:"duration"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
|
|
// ActivityDetail represents comprehensive activity data
|
|
type ActivityDetail struct {
|
|
Activity
|
|
Calories float64 `json:"calories"`
|
|
AverageHR int `json:"averageHR"`
|
|
MaxHR int `json:"maxHR"`
|
|
AverageTemp float64 `json:"averageTemperature"`
|
|
ElevationGain float64 `json:"elevationGain"`
|
|
ElevationLoss float64 `json:"elevationLoss"`
|
|
Weather Weather `json:"weather"`
|
|
Gear Gear `json:"gear"`
|
|
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
|
}
|
|
|
|
// garminTime implements custom JSON unmarshaling for Garmin's time format
|
|
type garminTime struct {
|
|
time.Time
|
|
}
|
|
|
|
const garminTimeLayout = "2006-01-02T15:04:05"
|
|
|
|
func (gt *garminTime) UnmarshalJSON(data []byte) error {
|
|
var s string
|
|
if err := json.Unmarshal(data, &s); err != nil {
|
|
return err
|
|
}
|
|
t, err := time.Parse(garminTimeLayout, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gt.Time = t
|
|
return nil
|
|
}
|
|
|
|
// ActivityResponse is used for JSON unmarshaling with custom time handling
|
|
type ActivityResponse struct {
|
|
ActivityID int64 `json:"activityId"`
|
|
Name string `json:"activityName"`
|
|
Type string `json:"activityType"`
|
|
StartTime garminTime `json:"startTimeLocal"`
|
|
Duration float64 `json:"duration"`
|
|
Distance float64 `json:"distance"`
|
|
}
|
|
|
|
// ActivityDetailResponse is used for JSON unmarshaling with custom time handling
|
|
type ActivityDetailResponse struct {
|
|
ActivityResponse
|
|
Calories float64 `json:"calories"`
|
|
AverageHR int `json:"averageHR"`
|
|
MaxHR int `json:"maxHR"`
|
|
AverageTemp float64 `json:"averageTemperature"`
|
|
ElevationGain float64 `json:"elevationGain"`
|
|
ElevationLoss float64 `json:"elevationLoss"`
|
|
Weather Weather `json:"weather"`
|
|
Gear Gear `json:"gear"`
|
|
GPSTracks []GPSTrackPoint `json:"gpsTracks"`
|
|
}
|
|
|
|
// Convert to ActivityDetail
|
|
func (adr *ActivityDetailResponse) ToActivityDetail() ActivityDetail {
|
|
return ActivityDetail{
|
|
Activity: Activity{
|
|
ActivityID: adr.ActivityID,
|
|
Name: adr.Name,
|
|
Type: adr.Type,
|
|
StartTime: adr.StartTime.Time,
|
|
Duration: adr.Duration,
|
|
Distance: adr.Distance,
|
|
},
|
|
Calories: adr.Calories,
|
|
AverageHR: adr.AverageHR,
|
|
MaxHR: adr.MaxHR,
|
|
AverageTemp: adr.AverageTemp,
|
|
ElevationGain: adr.ElevationGain,
|
|
ElevationLoss: adr.ElevationLoss,
|
|
Weather: adr.Weather,
|
|
Gear: adr.Gear,
|
|
GPSTracks: adr.GPSTracks,
|
|
}
|
|
}
|
|
|
|
// Convert to Activity
|
|
func (ar *ActivityResponse) ToActivity() Activity {
|
|
return Activity{
|
|
ActivityID: ar.ActivityID,
|
|
Name: ar.Name,
|
|
Type: ar.Type,
|
|
StartTime: ar.StartTime.Time,
|
|
Duration: ar.Duration,
|
|
Distance: ar.Distance,
|
|
}
|
|
}
|
|
|
|
// Weather contains weather conditions during activity
|
|
type Weather struct {
|
|
Condition string `json:"condition"`
|
|
Temperature float64 `json:"temperature"`
|
|
Humidity float64 `json:"humidity"`
|
|
}
|
|
|
|
// Gear represents equipment used in activity
|
|
type Gear struct {
|
|
ID string `json:"gearId"`
|
|
Name string `json:"name"`
|
|
Model string `json:"model"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
// GPSTrackPoint contains geo coordinates
|
|
type GPSTrackPoint struct {
|
|
Lat float64 `json:"lat"`
|
|
Lon float64 `json:"lon"`
|
|
Ele float64 `json:"ele"`
|
|
Timestamp time.Time `json:"timestamp"`
|
|
}
|
|
|
|
func (gtp *GPSTrackPoint) UnmarshalJSON(data []byte) error {
|
|
type Alias GPSTrackPoint
|
|
aux := &struct {
|
|
Timestamp string `json:"timestamp"`
|
|
*Alias
|
|
}{
|
|
Alias: (*Alias)(gtp),
|
|
}
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
|
|
if aux.Timestamp != "" {
|
|
t, err := time.Parse(garminTimeLayout, aux.Timestamp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
gtp.Timestamp = t
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ActivitiesResponse represents the response from the activities endpoint
|
|
type ActivitiesResponse struct {
|
|
Activities []ActivityResponse `json:"activities"`
|
|
Pagination Pagination `json:"pagination"`
|
|
}
|
|
|
|
// Pagination represents pagination information in API responses
|
|
type Pagination struct {
|
|
PageSize int `json:"pageSize"`
|
|
TotalCount int `json:"totalCount"`
|
|
Page int `json:"page"`
|
|
}
|
|
|
|
// GetActivities retrieves a list of activities with pagination
|
|
func (c *Client) GetActivities(ctx context.Context, page int, pageSize int) ([]Activity, *Pagination, error) {
|
|
path := "/activitylist-service/activities/search"
|
|
params := url.Values{}
|
|
params.Add("page", strconv.Itoa(page))
|
|
params.Add("pageSize", strconv.Itoa(pageSize))
|
|
|
|
var response ActivitiesResponse
|
|
err := c.Get(ctx, fmt.Sprintf("%s?%s", path, params.Encode()), &response)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get activities: %w", err)
|
|
}
|
|
|
|
// Convert response to Activity slice
|
|
activities := make([]Activity, len(response.Activities))
|
|
for i, ar := range response.Activities {
|
|
activities[i] = ar.ToActivity()
|
|
}
|
|
|
|
// Validate we received some activities
|
|
if len(activities) == 0 {
|
|
return nil, nil, fmt.Errorf("no activities found")
|
|
}
|
|
|
|
return activities, &response.Pagination, nil
|
|
}
|
|
|
|
// GetActivityDetails retrieves comprehensive data for a specific activity
|
|
func (c *Client) GetActivityDetails(ctx context.Context, activityID int64) (*ActivityDetail, error) {
|
|
path := fmt.Sprintf("/activity-service/activity/%d", activityID)
|
|
|
|
var response ActivityDetailResponse
|
|
err := c.Get(ctx, path, &response)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get activity details: %w", err)
|
|
}
|
|
|
|
activityDetail := response.ToActivityDetail()
|
|
|
|
// Validate we received activity data
|
|
if activityDetail.ActivityID == 0 {
|
|
return nil, fmt.Errorf("no activity found for ID %d", activityID)
|
|
}
|
|
|
|
return &activityDetail, nil
|
|
}
|
|
|
|
// UploadActivity handles FIT file uploads
|
|
func (c *Client) UploadActivity(ctx context.Context, fitFile []byte) (int64, error) {
|
|
path := "/upload-service/upload/.fit"
|
|
|
|
// Validate FIT file
|
|
if valid := fit.Validate(fitFile); !valid {
|
|
return 0, fmt.Errorf("invalid FIT file: signature verification failed")
|
|
}
|
|
|
|
// Prepare multipart form
|
|
body := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("file", "activity.fit")
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err = io.Copy(part, bytes.NewReader(fitFile)); err != nil {
|
|
return 0, err
|
|
}
|
|
writer.Close()
|
|
|
|
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
|
|
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return 0, fmt.Errorf("upload failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
// Parse response to get activity ID
|
|
var result struct {
|
|
ActivityID int64 `json:"activityId"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return result.ActivityID, nil
|
|
}
|
|
|
|
// DownloadActivity retrieves a FIT file for an activity
|
|
func (c *Client) DownloadActivity(ctx context.Context, activityID int64) ([]byte, error) {
|
|
path := fmt.Sprintf("/download-service/export/activity/%d", activityID)
|
|
|
|
fullURL := c.baseURL.ResolveReference(&url.URL{Path: path}).String()
|
|
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/fit")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
|
}
|
|
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
// Validate FIT file structure
|
|
func ValidateFIT(fitFile []byte) error {
|
|
if len(fitFile) < fit.MinFileSize() {
|
|
return fmt.Errorf("file too small to be a valid FIT file")
|
|
}
|
|
if string(fitFile[8:12]) != ".FIT" {
|
|
return fmt.Errorf("invalid FIT file signature")
|
|
}
|
|
return nil
|
|
}
|