package ride import ( "bytes" "encoding/binary" "errors" "io" "math" "time" "github.com/jftuga/geodist" "github.com/martinlehoux/kagamigo/kcore" "github.com/tormoder/fit" ) // FITRideParser parses FIT files into Ride values. It is the FIT counterpart // to GPXRideParser and keeps the same invariants. type FITRideParser struct{} func (p FITRideParser) Parse(reader io.Reader) (Ride, error) { data, err := io.ReadAll(reader) if err != nil { return Ride{}, err } file, err := fit.Decode(bytes.NewReader(data)) if err != nil { return Ride{}, err } activity, err := file.Activity() if err != nil { return Ride{}, err } if len(activity.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)) timestamps := make([]time.Time, 0, len(records)) heartRates := make([]float64, 0, len(records)) cadences := make([]float64, 0, len(records)) powers := make([]float64, 0, len(records)) heartRateComplete := true cadenceComplete := true powerComplete := true distance := 0.0 var previous *geodist.Coord for _, rec := range records { if rec.PositionLat.Invalid() || rec.PositionLong.Invalid() { continue } if rec.Altitude == 0xFFFF { return Ride{}, errors.New("points without elevation") } altitude := rec.GetAltitudeScaled() if math.IsNaN(altitude) { return Ride{}, errors.New("points without elevation") } coord := geodist.Coord{Lat: rec.PositionLat.Degrees(), Lon: rec.PositionLong.Degrees()} if previous != nil { _, km := geodist.HaversineDistance(*previous, coord) d := km * 1000 if d == 0 { continue } distance += d } previous = &coord distances = append(distances, distance) elevations = append(elevations, altitude) coords = append(coords, coord) // FIT timestamps are absolute; if missing they are zero time. ts := rec.Timestamp if ts.IsZero() { ts = file.FileId.TimeCreated } timestamps = append(timestamps, ts) if rec.HeartRate == 0xFF { heartRateComplete = false heartRates = append(heartRates, 0) } else { heartRates = append(heartRates, float64(rec.HeartRate)) } if rec.Cadence == 0xFF { cadenceComplete = false cadences = append(cadences, 0) } else { cadences = append(cadences, float64(rec.Cadence)) } if rec.Power == 0xFFFF { powerComplete = false powers = append(powers, 0) } else { powers = append(powers, float64(rec.Power)) } } if len(distances) < 2 { if len(distances) == 0 { return Ride{}, ErrNoTrackPoints } return Ride{}, errors.New("zero distance") } var heartRatesColumn, cadencesColumn, powersColumn []float64 if heartRateComplete { heartRatesColumn = heartRates } if cadenceComplete { cadencesColumn = cadences } if powerComplete { powersColumn = powers } return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil } // 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. func EncodeFIT(ride Ride, w io.Writer) error { kcore.Assert(ride.Len() > 0, "no points in ride") h := fit.NewHeader(fit.V20, true) file, err := fit.NewFile(fit.FileTypeActivity, h) if err != nil { return err } file.FileId.Manufacturer = fit.ManufacturerGarmin file.FileId.Type = fit.FileTypeActivity if len(ride.timestamps) > 0 { file.FileId.TimeCreated = ride.timestamps[0] } else { file.FileId.TimeCreated = time.Now() } activity, err := file.Activity() if err != nil { return err } heartRates, hasHR := ride.HeartRates() cadences, hasCad := ride.Cadences() powers, hasPower := ride.Powers() for i := 0; i < ride.Len(); i++ { rec := fit.NewRecordMsg() rec.Timestamp = ride.timestamps[i] coord := ride.Coords[i] rec.PositionLat = fit.NewLatitudeDegrees(coord.Lat) rec.PositionLong = fit.NewLongitudeDegrees(coord.Lon) // Altitude: scaled (value+500)*5, invalid 0xFFFF elev := ride.Elevations[i] scaled := int((elev + 500) * 5) if scaled < 0 { scaled = 0 } if scaled > 0xFFFE { scaled = 0xFFFE } rec.Altitude = uint16(scaled) rec.EnhancedAltitude = 0xFFFFFFFF if hasHR { v := int(heartRates[i]) if v < 0 { v = 0 } if v > 254 { v = 254 } rec.HeartRate = uint8(v) } else { rec.HeartRate = 0xFF } if hasCad { v := int(cadences[i]) if v < 0 { v = 0 } if v > 254 { v = 254 } rec.Cadence = uint8(v) } else { rec.Cadence = 0xFF } if hasPower { v := int(powers[i]) if v < 0 { v = 0 } if v > 65534 { v = 65534 } rec.Power = uint16(v) } else { rec.Power = 0xFFFF } // Explicitly mark unused fields as invalid to keep encoding minimal. rec.Distance = 0xFFFFFFFF rec.Speed = 0xFFFF rec.Grade = 0x7FFF activity.Records = append(activity.Records, rec) } return fit.Encode(w, file, binary.LittleEndian) }