aboutsummaryrefslogtreecommitdiff
path: root/ride
diff options
context:
space:
mode:
Diffstat (limited to 'ride')
-rw-r--r--ride/fit.go32
-rw-r--r--ride/fit_test.go74
2 files changed, 101 insertions, 5 deletions
diff --git a/ride/fit.go b/ride/fit.go
index db9c0b6..61d0a7b 100644
--- a/ride/fit.go
+++ b/ride/fit.go
@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/binary"
"errors"
+ "fmt"
"io"
"math"
"time"
@@ -13,8 +14,9 @@ import (
"github.com/tormoder/fit"
)
-// FITRideParser parses FIT files into Ride values. It is the FIT counterpart
-// to GPXRideParser and keeps the same invariants.
+// FITRideParser parses FIT Activity and Course files into Ride values. Both
+// types store their track as Record messages. It is the FIT counterpart to
+// GPXRideParser and keeps the same invariants.
type FITRideParser struct{}
func (p FITRideParser) Parse(reader io.Reader) (Ride, error) {
@@ -26,17 +28,16 @@ func (p FITRideParser) Parse(reader io.Reader) (Ride, error) {
if err != nil {
return Ride{}, err
}
- activity, err := file.Activity()
+ records, err := fitRecords(file)
if err != nil {
return Ride{}, err
}
- if len(activity.Records) == 0 {
+ if len(records) == 0 {
return Ride{}, ErrNoTrackPoints
}
// Collect points discarding invalid positions — FIT encodes missing lat/lon
// as 0x7FFFFFFF (Invalid). We also require altitude.
- records := activity.Records
distances := make([]float64, 0, len(records))
elevations := make([]float64, 0, len(records))
coords := make([]geodist.Coord, 0, len(records))
@@ -123,6 +124,27 @@ func (p FITRideParser) Parse(reader io.Reader) (Ride, error) {
return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil
}
+// fitRecords returns the track points of an Activity or Course FIT file; both
+// store them as Record messages.
+func fitRecords(file *fit.File) ([]*fit.RecordMsg, error) {
+ switch file.Type() {
+ case fit.FileTypeActivity:
+ activity, err := file.Activity()
+ if err != nil {
+ return nil, err
+ }
+ return activity.Records, nil
+ case fit.FileTypeCourse:
+ course, err := file.Course()
+ if err != nil {
+ return nil, err
+ }
+ return course.Records, nil
+ default:
+ return nil, fmt.Errorf("fit file type is %v, not Activity or Course", file.Type())
+ }
+}
+
// EncodeFIT writes the ride as a minimal FIT Activity file. It stores only the
// fields consumed by Ride (lat/lon, altitude, time, heart rate, cadence, power)
// as Record messages.
diff --git a/ride/fit_test.go b/ride/fit_test.go
new file mode 100644
index 0000000..9e9e0a3
--- /dev/null
+++ b/ride/fit_test.go
@@ -0,0 +1,74 @@
+package ride_test
+
+import (
+ "bytes"
+ "encoding/binary"
+ "testing"
+ "time"
+
+ "github.com/martinlehoux/biking_home/ride"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "github.com/tormoder/fit"
+)
+
+func encodeFIT(t *testing.T, fileType fit.FileType, records []*fit.RecordMsg) []byte {
+ t.Helper()
+ file, err := fit.NewFile(fileType, fit.NewHeader(fit.V20, true))
+ require.NoError(t, err)
+ file.FileId.Manufacturer = fit.ManufacturerGarmin
+ file.FileId.Type = fileType
+ file.FileId.TimeCreated = time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC)
+ switch fileType {
+ case fit.FileTypeCourse:
+ course, err := file.Course()
+ require.NoError(t, err)
+ course.Records = records
+ case fit.FileTypeActivity:
+ activity, err := file.Activity()
+ require.NoError(t, err)
+ activity.Records = records
+ }
+ var buf bytes.Buffer
+ require.NoError(t, fit.Encode(&buf, file, binary.LittleEndian))
+ return buf.Bytes()
+}
+
+func fitRecord(lat, lon, elevationM float64, ts time.Time) *fit.RecordMsg {
+ rec := fit.NewRecordMsg()
+ rec.Timestamp = ts
+ rec.PositionLat = fit.NewLatitudeDegrees(lat)
+ rec.PositionLong = fit.NewLongitudeDegrees(lon)
+ rec.Altitude = uint16((elevationM + 500) * 5)
+ rec.HeartRate = 0xFF
+ rec.Cadence = 0xFF
+ rec.Power = 0xFFFF
+ return rec
+}
+
+func TestFITRideParserParsesCourseFile(t *testing.T) {
+ base := time.Date(2026, 8, 1, 7, 30, 0, 0, time.UTC)
+ data := encodeFIT(t, fit.FileTypeCourse, []*fit.RecordMsg{
+ fitRecord(43.0, 5.0, 100, base),
+ fitRecord(43.0, 5.0, 101, base.Add(5*time.Second)), // stationary: skipped
+ fitRecord(43.001, 5.001, 200, base.Add(10*time.Second)),
+ })
+
+ parsed, err := (ride.FITRideParser{}).Parse(bytes.NewReader(data))
+ require.NoError(t, err)
+ require.Equal(t, 2, parsed.Len())
+ assert.Equal(t, 0.0, parsed.Distances[0])
+ assert.Greater(t, parsed.Distances[1], 0.0)
+ assert.Equal(t, []float64{100, 200}, parsed.Elevations)
+ assert.Equal(t, []time.Time{base, base.Add(10 * time.Second)}, parsed.Timestamps())
+ _, hasHR := parsed.HeartRates()
+ assert.False(t, hasHR, "course records carry no telemetry")
+}
+
+func TestFITRideParserRejectsFileWithoutTrack(t *testing.T) {
+ data := encodeFIT(t, fit.FileTypeSettings, nil)
+
+ _, err := (ride.FITRideParser{}).Parse(bytes.NewReader(data))
+ require.Error(t, err)
+ assert.Equal(t, "fit file type is Settings, not Activity or Course", err.Error())
+}