package strava import ( "bytes" "errors" "fmt" "net/url" "strconv" "time" "github.com/jftuga/geodist" "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" "github.com/tkrajina/gpxgo/gpx" ) const garminTrackPointExtensionNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1" var ErrNoTrackPoints = errors.New("activity has no track points") type Activity struct { ID int64 `json:"id"` Name string `json:"name"` Type string `json:"type"` SportType string `json:"sport_type"` StartDate time.Time `json:"start_date"` DistanceM float64 `json:"distance"` MovingTimeS int64 `json:"moving_time"` ElapsedTimeS int64 `json:"elapsed_time"` TotalElevationGainM float64 `json:"total_elevation_gain"` AverageSpeedMps float64 `json:"average_speed"` } func (c *Client) List(from, to time.Time) ([]Activity, error) { return c.list(from, to, "Ride", "MountainBikeRide") } func (c *Client) list(from, to time.Time, types ...string) ([]Activity, error) { allowed := map[string]bool{} for _, t := range types { allowed[t] = true } var all []Activity for page := 1; ; page++ { q := url.Values{} q.Set("per_page", "200") q.Set("page", strconv.Itoa(page)) if !from.IsZero() { q.Set("after", strconv.FormatInt(from.Unix(), 10)) } if !to.IsZero() { q.Set("before", strconv.FormatInt(to.Unix(), 10)) } var batch []Activity err := c.GetJSON("/athlete/activities?"+q.Encode(), &batch) if err != nil { return nil, kcore.Wrap(err, "failed to list Strava activities") } if len(batch) == 0 { break } for _, activity := range batch { if len(allowed) == 0 || allowed[activity.Type] || allowed[activity.SportType] { all = append(all, activity) } } if len(batch) < 200 { break } } return all, nil } func (c *Client) Get(id int64) (Activity, []byte, []byte, error) { var activity Activity if err := c.GetJSON(fmt.Sprintf("/activities/%d", id), &activity); err != nil { return Activity{}, nil, nil, kcore.Wrap(err, "failed to get Strava activity") } streams, err := c.activityStreams(id) if err != nil { return Activity{}, nil, nil, err } gpxData, err := activityGPX(activity, streams) if err != nil { return activity, nil, nil, err } fitData, err := activityFIT(activity, streams) if err != nil { return activity, nil, nil, err } return activity, gpxData, fitData, nil } type activityStreams struct { LatLng [][2]float64 Altitude []float64 Seconds []float64 HeartRate []*float64 Cadence []*float64 Power []*float64 } type nullableFloatStream struct { Data []*float64 `json:"data"` } func (c *Client) activityStreams(id int64) (activityStreams, error) { path := fmt.Sprintf("/activities/%d/streams?keys=latlng,altitude,time,heartrate,cadence,watts&key_by_type=true", id) var streams struct { LatLng *struct { Data [][2]float64 `json:"data"` } `json:"latlng"` Altitude *struct { Data []float64 `json:"data"` } `json:"altitude"` Time *struct { Data []float64 `json:"data"` } `json:"time"` HeartRate *nullableFloatStream `json:"heartrate"` Cadence *nullableFloatStream `json:"cadence"` Power *nullableFloatStream `json:"watts"` } err := c.GetJSON(path, &streams) if err != nil { return activityStreams{}, kcore.Wrap(err, "failed to fetch Strava streams") } result := activityStreams{} if streams.LatLng != nil { result.LatLng = streams.LatLng.Data } if streams.Altitude != nil { result.Altitude = streams.Altitude.Data } if streams.Time != nil { result.Seconds = streams.Time.Data } if streams.HeartRate != nil { result.HeartRate = streams.HeartRate.Data } if streams.Cadence != nil { result.Cadence = streams.Cadence.Data } if streams.Power != nil { result.Power = streams.Power.Data } return result, nil } func activityFIT(activity Activity, streams activityStreams) ([]byte, error) { if len(streams.LatLng) == 0 { return nil, ErrNoTrackPoints } n := len(streams.LatLng) distances := make([]float64, 0, n) elevations := make([]float64, 0, n) coords := make([]geodist.Coord, 0, n) timestamps := make([]time.Time, 0, n) heartRates := make([]float64, 0, n) cadences := make([]float64, 0, n) powers := make([]float64, 0, n) heartRateComplete := true cadenceComplete := true powerComplete := true distance := 0.0 var previous *geodist.Coord for i := range n { coord := geodist.Coord{Lat: streams.LatLng[i][0], Lon: streams.LatLng[i][1]} if previous != nil { _, km := geodist.HaversineDistance(*previous, coord) d := km * 1000 if d == 0 { continue } distance += d } else { distance = 0 } previous = &coord var elev float64 if i < len(streams.Altitude) { elev = streams.Altitude[i] } // If altitude missing and we have no value, use 0 to keep ride valid. // The GPX path requires elevation; this mirrors that requirement. distances = append(distances, distance) elevations = append(elevations, elev) coords = append(coords, coord) var ts time.Time if i < len(streams.Seconds) { ts = activity.StartDate.Add(time.Duration(streams.Seconds[i]) * time.Second) } else { ts = activity.StartDate.Add(time.Duration(i) * time.Second) } timestamps = append(timestamps, ts) if hr := streamValue(streams.HeartRate, i); hr == nil { heartRateComplete = false heartRates = append(heartRates, 0) } else { heartRates = append(heartRates, *hr) } if cad := streamValue(streams.Cadence, i); cad == nil { cadenceComplete = false cadences = append(cadences, 0) } else { cadences = append(cadences, *cad) } if pow := streamValue(streams.Power, i); pow == nil { powerComplete = false powers = append(powers, 0) } else { powers = append(powers, *pow) } } if len(distances) < 2 { return nil, errors.New("zero distance") } var hrCol, cadCol, powCol []float64 if heartRateComplete { hrCol = heartRates } if cadenceComplete { cadCol = cadences } if powerComplete { powCol = powers } r := ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, hrCol, cadCol, powCol) var buf bytes.Buffer if err := ride.EncodeFIT(r, &buf); err != nil { return nil, err } return buf.Bytes(), nil } func activityGPX(activity Activity, streams activityStreams) ([]byte, error) { if len(streams.LatLng) == 0 { return nil, ErrNoTrackPoints } points := make([]gpx.GPXPoint, len(streams.LatLng)) for i := range streams.LatLng { point := gpx.GPXPoint{ Latitude: streams.LatLng[i][0], Longitude: streams.LatLng[i][1], } if i < len(streams.Altitude) { point.Elevation = *gpx.NewNullableFloat64(streams.Altitude[i]) } if i < len(streams.Seconds) { point.Timestamp = activity.StartDate.Add(time.Duration(streams.Seconds[i]) * time.Second) } addTrackPointMetric(&point, "hr", streamValue(streams.HeartRate, i)) addTrackPointMetric(&point, "cad", streamValue(streams.Cadence, i)) addTrackPointMetric(&point, "watts", streamValue(streams.Power, i)) points[i] = point } doc := gpx.GPX{ Version: "1.1", Creator: "biking_home", Name: activity.Name, Tracks: []gpx.GPXTrack{{ Name: activity.Name, Segments: []gpx.GPXTrackSegment{{Points: points}}, }}, } doc.RegisterNamespace("gpxtpx", garminTrackPointExtensionNamespace) xmlData, err := doc.ToXml(gpx.ToXmlParams{}) if err != nil { return nil, kcore.Wrap(err, "failed to build GPX XML") } return xmlData, nil } func streamValue(stream []*float64, i int) *float64 { if i >= len(stream) { return nil } return stream[i] } func addTrackPointMetric(point *gpx.GPXPoint, name string, value *float64) { if value == nil { return } extension := point.Extensions.GetOrCreateNode(gpx.NamespaceURL(garminTrackPointExtensionNamespace), "TrackPointExtension") extension.GetOrCreateNode(name).Data = strconv.FormatFloat(*value, 'f', -1, 64) }